diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 066f4f5e..eb63fb47 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -9,6 +9,9 @@ runs: with: node-version-file: .nvmrc + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + - name: Restore dependencies id: yarn-cache uses: actions/cache/restore@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40cc9841..623d50b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,14 @@ jobs: exit 1 fi + - name: Validate .riv schema files + run: | + yarn rive-gen-types --all example/assets/rive + if ! git diff --exit-code 'example/assets/rive/*.riv.d.ts'; then + echo "Error: .riv.d.ts files are out of date. Please run 'yarn rive-gen-types --all example/assets/rive' and commit the changes." + exit 1 + fi + - name: Typecheck files run: yarn typecheck diff --git a/eslint.config.mjs b/eslint.config.mjs index 66f18495..89c2a2cc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -58,6 +58,7 @@ export default defineConfig([ // tsd owns *.test-d.ts (yarn typetest) — deprecated calls there are // intentional expectDeprecated() pins, not violations. '**/*.test-d.ts', +'**/*.riv.d.ts', // Agent worktrees (.claude/worktrees/*) are gitignored full-repo copies; // linting them duplicates every finding and slows the run. '**/.claude/', diff --git a/example/assets/rive/GradientBorder.riv b/example/assets/rive/GradientBorder.riv new file mode 100644 index 00000000..aa883225 Binary files /dev/null and b/example/assets/rive/GradientBorder.riv differ diff --git a/example/assets/rive/GradientBorder.riv.d.ts b/example/assets/rive/GradientBorder.riv.d.ts new file mode 100644 index 00000000..a964f379 --- /dev/null +++ b/example/assets/rive/GradientBorder.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: GradientBorder.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Layout'; + defaultArtboard: 'Layout'; + stateMachines: { + Layout: 'State'; + }; + viewModels: { + ViewModel: { + isFocused: 'boolean'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/arbtboards-models-instances.riv.d.ts b/example/assets/rive/arbtboards-models-instances.riv.d.ts new file mode 100644 index 00000000..9da47486 --- /dev/null +++ b/example/assets/rive/arbtboards-models-instances.riv.d.ts @@ -0,0 +1,26 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: arbtboards-models-instances.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'artboard1' | 'artboard3' | 'artboard2' | 'artboardc1'; + defaultArtboard: 'artboard1'; + stateMachines: { + artboard1: 'sm13' | 'sm12' | 'sm11'; + artboard3: 'sm33' | 'sm32' | 'sm31'; + artboard2: 'sm23' | 'sm22' | 'sm21'; + artboardc1: 'State Machine 1'; + }; + viewModels: { + viewmodel1: { + _id: 'string'; + }; + viewmodel2: { + _id: 'string'; + }; + viewmodel3: { + _id: 'string'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/artboard_db_test.riv.d.ts b/example/assets/rive/artboard_db_test.riv.d.ts new file mode 100644 index 00000000..6b8b0eb6 --- /dev/null +++ b/example/assets/rive/artboard_db_test.riv.d.ts @@ -0,0 +1,21 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: artboard_db_test.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Main' | 'ArtboardGreen' | 'ArtboardBlue' | 'ArtboardRed'; + defaultArtboard: 'Main'; + stateMachines: { + Main: 'State Machine 1'; + ArtboardGreen: 'State Machine 1'; + ArtboardBlue: 'State Machine 1'; + ArtboardRed: 'State Machine 1'; + }; + viewModels: { + MainViewModel: { + artboard_1: 'artboard'; + artboard_2: 'artboard'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/blinko.riv.d.ts b/example/assets/rive/blinko.riv.d.ts new file mode 100644 index 00000000..8266f094 --- /dev/null +++ b/example/assets/rive/blinko.riv.d.ts @@ -0,0 +1,97 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: blinko.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Main' | 'StoreItem' | 'Token' | 'Indicator' | 'Puck' | 'StoreIcon' | 'Peg' | 'scoreBox'; + defaultArtboard: 'Main'; + stateMachines: { + Main: 'State Machine 1'; + StoreItem: 'State Machine 1'; + Token: 'State Machine 1'; + Indicator: 'State Machine 1'; + Puck: 'State Machine 1'; + StoreIcon: 'State Machine 1'; + Peg: 'State Machine 1'; + scoreBox: 'State Machine 1'; + }; + viewModels: { + IndicatorVM: { + purchased: 'boolean'; + }; + StoreItemVM: { + tokenCount: 'number'; + height: 'number'; + itemHover: 'boolean'; + cost: 'number'; + description: 'string'; + id: 'string'; + itemType: 'string'; + maxPurchases: 'number'; + name: 'string'; + purchase: 'trigger'; + purchaseCount: 'number'; + }; + storeIconVM: { + storeiconClick: 'trigger'; + iconHovered: 'boolean'; + }; + storeVM: { + 'xbuttonClick': 'trigger'; + 'xbuttonHover': 'boolean'; + 'items': 'list'; + 'storeOpen': 'boolean'; + 'property of pegVM': 'viewModel:pegVM'; + 'multiplierValue': 'number'; + 'extraTokenSlotActive': 'boolean'; + 'extraTokenSlotCost': 'number'; + 'extraTokenSlotCount': 'number'; + 'purchaseExtraTokenSlot': 'trigger'; + 'purchaseSuperMultiplier': 'trigger'; + 'superMultiplierActive': 'boolean'; + 'superMultiplierCost': 'number'; + 'superMultiplierCount': 'number'; + }; + TokenVM: { + spinToken: 'boolean'; + }; + pegVM: { + blink: 'trigger'; + multiplierValue: 'number'; + pegType: 'enum:normal|multiplier'; + pegBounced: 'trigger'; + }; + gameLogicVM: { + turnTokens: 'number'; + turnScore: 'number'; + buttonLabel: 'string'; + message: 'string'; + startTurn: 'trigger'; + turnEnded: 'trigger'; + gameEnded: 'boolean'; + tokenCount: 'number'; + scoreTotal: 'number'; + }; + scoreBox: { + Token: 'viewModel:TokenVM'; + slotType: 'enum:addToken|normal'; + landed: 'trigger'; + scoreValue: 'number'; + }; + PuckVM: { + puckBounce: 'trigger'; + puckRotation: 'number'; + }; + main: { + 'PuckVM': 'viewModel:PuckVM'; + 'storeItemVM': 'viewModel:StoreItemVM'; + 'storeIconVM': 'viewModel:storeIconVM'; + 'pegVM': 'viewModel:pegVM'; + 'storeVM': 'viewModel:storeVM'; + 'buttonHover': 'boolean'; + 'tokenVM': 'viewModel:TokenVM'; + 'property of gameLogicVM': 'viewModel:gameLogicVM'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/bouncing_ball.riv.d.ts b/example/assets/rive/bouncing_ball.riv.d.ts new file mode 100644 index 00000000..10bc5f04 --- /dev/null +++ b/example/assets/rive/bouncing_ball.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: bouncing_ball.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + vm: { + ypos: 'number'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/counter.riv.d.ts b/example/assets/rive/counter.riv.d.ts new file mode 100644 index 00000000..244bbe14 --- /dev/null +++ b/example/assets/rive/counter.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: counter.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + ViewModel1: { + cnt: 'number'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/databinding.riv.d.ts b/example/assets/rive/databinding.riv.d.ts new file mode 100644 index 00000000..f0b630e5 --- /dev/null +++ b/example/assets/rive/databinding.riv.d.ts @@ -0,0 +1,28 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: databinding.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + Person: { + pet: 'viewModel:Pet'; + jump: 'trigger'; + likes_popcorn: 'boolean'; + favourite_pet: 'enum:chipmunk|rat|frog|owl|cat|dog'; + favourite_color: 'color'; + age: 'number'; + website: 'string'; + name: 'string'; + }; + Pet: { + type: 'enum:chipmunk|rat|frog|owl|cat|dog'; + name: 'string'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/databinding_images.riv.d.ts b/example/assets/rive/databinding_images.riv.d.ts new file mode 100644 index 00000000..b9d93374 --- /dev/null +++ b/example/assets/rive/databinding_images.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: databinding_images.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + MyViewModel: { + bound_image: 'image'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/databinding_lists.riv.d.ts b/example/assets/rive/databinding_lists.riv.d.ts new file mode 100644 index 00000000..00334476 --- /dev/null +++ b/example/assets/rive/databinding_lists.riv.d.ts @@ -0,0 +1,20 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: databinding_lists.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + Person: { + name: 'string'; + }; + DevRel: { + team: 'list'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/fallback_fonts.riv.d.ts b/example/assets/rive/fallback_fonts.riv.d.ts new file mode 100644 index 00000000..60e8aa1d --- /dev/null +++ b/example/assets/rive/fallback_fonts.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: fallback_fonts.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/font_fallback.riv.d.ts b/example/assets/rive/font_fallback.riv.d.ts new file mode 100644 index 00000000..02bdda87 --- /dev/null +++ b/example/assets/rive/font_fallback.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: font_fallback.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + ViewModel: { + text: 'string'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/hello_world_text.riv.d.ts b/example/assets/rive/hello_world_text.riv.d.ts new file mode 100644 index 00000000..4e879f1e --- /dev/null +++ b/example/assets/rive/hello_world_text.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: hello_world_text.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'New Artboard'; + defaultArtboard: 'New Artboard'; + stateMachines: { + 'New Artboard': 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/inputglow.riv b/example/assets/rive/inputglow.riv new file mode 100644 index 00000000..85e96d39 Binary files /dev/null and b/example/assets/rive/inputglow.riv differ diff --git a/example/assets/rive/inputglow.riv.d.ts b/example/assets/rive/inputglow.riv.d.ts new file mode 100644 index 00000000..c1a84c21 --- /dev/null +++ b/example/assets/rive/inputglow.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: inputglow.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Layout'; + defaultArtboard: 'Layout'; + stateMachines: { + Layout: 'State'; + }; + viewModels: { + ViewModel: { + isFocused: 'boolean'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/ios_android_layouts_demo_v01.riv.d.ts b/example/assets/rive/ios_android_layouts_demo_v01.riv.d.ts new file mode 100644 index 00000000..549ba94c --- /dev/null +++ b/example/assets/rive/ios_android_layouts_demo_v01.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: ios_android_layouts_demo_v01.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'iOS_Android_Layouts_demo_v01'; + defaultArtboard: 'iOS_Android_Layouts_demo_v01'; + stateMachines: { + iOS_Android_Layouts_demo_v01: 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/layout_test.riv b/example/assets/rive/layout_test.riv new file mode 100644 index 00000000..24f2f818 Binary files /dev/null and b/example/assets/rive/layout_test.riv differ diff --git a/example/assets/rive/layout_test.riv.d.ts b/example/assets/rive/layout_test.riv.d.ts new file mode 100644 index 00000000..25234ee9 --- /dev/null +++ b/example/assets/rive/layout_test.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: layout_test.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + ViewModel1: { + + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/layouts_demo.riv.d.ts b/example/assets/rive/layouts_demo.riv.d.ts new file mode 100644 index 00000000..ac330ad0 --- /dev/null +++ b/example/assets/rive/layouts_demo.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: layouts_demo.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'iOS_Android_Layouts_demo_v01'; + defaultArtboard: 'iOS_Android_Layouts_demo_v01'; + stateMachines: { + iOS_Android_Layouts_demo_v01: 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/many_viewmodels.riv.d.ts b/example/assets/rive/many_viewmodels.riv.d.ts new file mode 100644 index 00000000..4e717058 --- /dev/null +++ b/example/assets/rive/many_viewmodels.riv.d.ts @@ -0,0 +1,19 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: many_viewmodels.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + First: { + imageValue: 'image'; + colorValue: 'color'; + textValue: 'string'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/movecircle.riv.d.ts b/example/assets/rive/movecircle.riv.d.ts new file mode 100644 index 00000000..229ae35c --- /dev/null +++ b/example/assets/rive/movecircle.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: movecircle.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + ViewModel: { + posY: 'number'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/nodefaultbouncing.riv.d.ts b/example/assets/rive/nodefaultbouncing.riv.d.ts new file mode 100644 index 00000000..60e32056 --- /dev/null +++ b/example/assets/rive/nodefaultbouncing.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: nodefaultbouncing.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + VM1: { + + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/on_entry_test.riv.d.ts b/example/assets/rive/on_entry_test.riv.d.ts new file mode 100644 index 00000000..56b087dc --- /dev/null +++ b/example/assets/rive/on_entry_test.riv.d.ts @@ -0,0 +1,18 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: on_entry_test.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + vm: { + input: 'number'; + inputOnEntry: 'number'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/out_of_band.riv.d.ts b/example/assets/rive/out_of_band.riv.d.ts new file mode 100644 index 00000000..9546dc6a --- /dev/null +++ b/example/assets/rive/out_of_band.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: out_of_band.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/quick_start.riv.d.ts b/example/assets/rive/quick_start.riv.d.ts new file mode 100644 index 00000000..4847c6a7 --- /dev/null +++ b/example/assets/rive/quick_start.riv.d.ts @@ -0,0 +1,21 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: quick_start.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'health_bar_v01'; + defaultArtboard: 'health_bar_v01'; + stateMachines: { + health_bar_v01: 'State Machine 1'; + }; + viewModels: { + health_bar_01: { + gameOver: 'trigger'; + hoverYes: 'boolean'; + hoverNo: 'boolean'; + healthColor: 'color'; + health: 'number'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/rating.riv.d.ts b/example/assets/rive/rating.riv.d.ts new file mode 100644 index 00000000..35be7041 --- /dev/null +++ b/example/assets/rive/rating.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: rating.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'New Artboard'; + defaultArtboard: 'New Artboard'; + stateMachines: { + 'New Artboard': 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/rewards.riv.d.ts b/example/assets/rive/rewards.riv.d.ts new file mode 100644 index 00000000..85ed68a4 --- /dev/null +++ b/example/assets/rive/rewards.riv.d.ts @@ -0,0 +1,51 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: rewards.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Main' | 'Lives 2' | 'Item Value' | 'Chest' | 'Button' | 'Heart' | 'Item'; + defaultArtboard: 'Main'; + stateMachines: { + 'Main': 'State Machine 1'; + 'Lives 2': 'State Machine 1'; + 'Item Value': 'State Machine 1'; + 'Chest': 'State Machine 1'; + 'Button': 'State Machine 1'; + 'Heart': 'State Machine 1'; + 'Item': 'State Machine 1'; + }; + viewModels: { + Item_Icon_Value: { + Icon_React: 'trigger'; + Property_Of_Item: 'viewModel:Item'; + Item_Value: 'number'; + }; + Energy_Bar: { + Bar_Color: 'color'; + Lives: 'number'; + Energy_Bar: 'number'; + }; + Rewards: { + Price_Value: 'number'; + Color: 'color'; + Height: 'number'; + With: 'number'; + Item_Selection: 'viewModel:Item'; + Item_Value_Icon: 'viewModel:Item_Icon_Value'; + Button: 'viewModel:Button'; + Coin: 'viewModel:Item_Icon_Value'; + Gem: 'viewModel:Item_Icon_Value'; + Energy_Bar: 'viewModel:Energy_Bar'; + }; + Button: { + State_1: 'string'; + Item_Text: 'string'; + Item: 'viewModel:Item'; + Pressed: 'trigger'; + }; + Item: { + Item_Selection: 'enum:Coin|Gem'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/style_fallback_fonts.riv.d.ts b/example/assets/rive/style_fallback_fonts.riv.d.ts new file mode 100644 index 00000000..ecd23149 --- /dev/null +++ b/example/assets/rive/style_fallback_fonts.riv.d.ts @@ -0,0 +1,12 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: style_fallback_fonts.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; +}>; +export default asset; diff --git a/example/assets/rive/viewmodelproperty.riv.d.ts b/example/assets/rive/viewmodelproperty.riv.d.ts new file mode 100644 index 00000000..949846e8 --- /dev/null +++ b/example/assets/rive/viewmodelproperty.riv.d.ts @@ -0,0 +1,21 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: viewmodelproperty.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + root: { + vm2: 'viewModel:vm'; + vm1: 'viewModel:vm'; + }; + vm: { + name: 'string'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/vm_value_change_test.riv b/example/assets/rive/vm_value_change_test.riv new file mode 100644 index 00000000..5dc931d6 Binary files /dev/null and b/example/assets/rive/vm_value_change_test.riv differ diff --git a/example/assets/rive/vm_value_change_test.riv.d.ts b/example/assets/rive/vm_value_change_test.riv.d.ts new file mode 100644 index 00000000..d092fc1f --- /dev/null +++ b/example/assets/rive/vm_value_change_test.riv.d.ts @@ -0,0 +1,18 @@ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: vm_value_change_test.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + TextModel: { + trigger: 'trigger'; + textValue: 'string'; + }; + }; +}>; +export default asset; diff --git a/example/src/exercisers/FileSwitcher.tsx b/example/src/exercisers/FileSwitcher.tsx new file mode 100644 index 00000000..d29671b6 --- /dev/null +++ b/example/src/exercisers/FileSwitcher.tsx @@ -0,0 +1,207 @@ +/** + * File Switcher Exerciser + * + * Rapidly switch between .riv files with different fit modes to test for + * crashes related to dispose/render races. + * + * Reproduces "Should not already be working" crash seen on RN 0.83. + */ + +import { useState } from 'react'; +import { + type DimensionValue, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { Fit, RiveView, useRiveFile } from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +const FILES = [ + { + label: 'layout_test', + source: require('../../assets/rive/layout_test.riv'), + }, + { label: 'inputglow', source: require('../../assets/rive/inputglow.riv') }, + { + label: 'GradientBorder', + source: require('../../assets/rive/GradientBorder.riv'), + }, + { + label: 'layouts_demo', + source: require('../../assets/rive/layouts_demo.riv'), + }, + { + label: 'bouncing_ball', + source: require('../../assets/rive/bouncing_ball.riv'), + }, + { label: 'counter', source: require('../../assets/rive/counter.riv') }, + { label: 'rating', source: require('../../assets/rive/rating.riv') }, +] as const; + +const FITS = [ + { label: 'Layout', value: Fit.Layout }, + { label: 'Contain', value: Fit.Contain }, + { label: 'Cover', value: Fit.Cover }, + { label: 'Fill', value: Fit.Fill }, +] as const; + +const SIZES = [ + { label: 'full x 150', width: '100%' as const, height: 150 }, + { label: 'full x 80', width: '100%' as const, height: 80 }, + { label: '200 x 120', width: 200, height: 120 }, +]; + +function RiveBox({ + source, + fit, + height, + width, +}: { + source: number; + fit: Fit; + height: number; + width: DimensionValue; +}) { + const { riveFile } = useRiveFile(source); + + return ( + + {riveFile && ( + + )} + + ); +} + +function Chip({ + label, + selected, + onPress, +}: { + label: string; + selected: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} + +export const metadata: Metadata = { + name: 'File Switcher', + description: 'Rapidly switch files/fits to test dispose/render race crashes', +}; + +export default function FileSwitcher() { + const [fileIdx, setFileIdx] = useState(0); + const [fitIdx, setFitIdx] = useState(0); + + const file = FILES[fileIdx]!; + const fit = FITS[fitIdx]!; + + return ( + + File + + {FILES.map((f, i) => ( + setFileIdx(i)} + /> + ))} + + + Fit + + {FITS.map((f, i) => ( + setFitIdx(i)} + /> + ))} + + + + {file.label} — Fit.{fit.label} + + {SIZES.map((size) => ( + + {size.label} + + + ))} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0c1027', + }, + content: { + padding: 20, + paddingBottom: 60, + }, + section: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + marginTop: 16, + marginBottom: 8, + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + chip: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: '#555', + }, + chipSelected: { + backgroundColor: '#4f6ef7', + borderColor: '#4f6ef7', + }, + chipText: { + color: '#999', + fontSize: 13, + }, + chipTextSelected: { + color: '#fff', + }, + item: { + marginBottom: 16, + }, + label: { + color: '#999', + fontSize: 12, + marginBottom: 4, + }, + riveBox: { + borderWidth: 1, + borderColor: 'red', + }, +}); diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index 48713b79..879138d7 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -5,19 +5,18 @@ import { RiveView, useRiveNumber, useViewModelInstance, - type ViewModelInstance, - type RiveFile, + type TypedViewModelOf, + type TypedRiveFile, useRiveString, useRiveColor, useRiveTrigger, useRiveFile, } from '@rive-app/react-native'; import { type Metadata } from '../shared/metadata'; +import rewardsRiv from '../../assets/rive/rewards.riv'; export default function WithRiveFile() { - const { riveFile, isLoading, error } = useRiveFile( - require('../../assets/rive/rewards.riv') - ); + const { riveFile, isLoading, error } = useRiveFile(rewardsRiv); return ( @@ -36,12 +35,19 @@ export default function WithRiveFile() { ); } -function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file, { async: true }); +function WithViewModelSetup({ + file, +}: { + file: TypedRiveFile; +}) { + const { instance, error } = useViewModelInstance(file, { + viewModelName: 'Rewards', + async: true, + }); if (error) { console.error(error.message); - return {error.message}; + return {error.message}; } if (!instance) { @@ -55,8 +61,8 @@ function DataBindingExample({ instance, file, }: { - instance: ViewModelInstance; - file: RiveFile; + instance: TypedViewModelOf; + file: TypedRiveFile; }) { const { error: coinValueError } = useRiveNumber('Coin/Item_Value', instance); @@ -64,7 +70,10 @@ function DataBindingExample({ console.error('coinValueError', coinValueError); } - const { setValue: setButtonText } = useRiveString('Button/State_1', instance); + const { setValue: setButtonText } = useRiveString( + 'Button/Item_Text', + instance + ); const { setValue: setBarColor, error: barColorError } = useRiveColor( 'Energy_Bar/Bar_Color', diff --git a/package.json b/package.json index 23cd3e63..d1f9c345 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", "prepare": "npx react-native-builder-bob@^0.40.0 build", "nitrogen": "nitrogen && npx tsx scripts/nitrogen-postprocess.ts", + "rive-gen-types": "bun scripts/rive-gen-types.ts", "release": "release-it", "dev:ios": "cd example && xed ios", "dev:android": "cd example && open -a \"/Applications/Android Studio.app\" ./android", @@ -47,7 +48,8 @@ "lint:swift": "./scripts/lint-swift.sh", "lint:kotlin": "./scripts/lint-kotlin.sh", "lint:native": "yarn lint:swift && yarn lint:kotlin", - "typetest": "tsd --typings src/index.tsx" + "typetest": "tsd --typings src/index.tsx", + "test:scripts": "cd scripts/__tests__ && bun test --no-parallel" }, "keywords": [ "react-native", @@ -79,6 +81,7 @@ "@react-native/babel-preset": "0.79.2", "@react-native/eslint-config": "^0.78.0", "@release-it/conventional-changelog": "^9.0.2", + "@rive-app/canvas": "^2.38.0", "@testing-library/react-hooks": "^8.0.1", "@testing-library/react-native": "^13.3.3", "@types/jest": "^29.5.5", diff --git a/scripts/__tests__/rive-extract-schema.test.ts b/scripts/__tests__/rive-extract-schema.test.ts new file mode 100644 index 00000000..f3265977 --- /dev/null +++ b/scripts/__tests__/rive-extract-schema.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect, beforeAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { resolve } from 'path'; + +const EXTRACTOR = resolve(__dirname, '../rive-extract-schema.ts'); +const REWARDS_RIV = resolve(__dirname, '../../example/assets/rive/rewards.riv'); + +function extract(path: string) { + const result = spawnSync('bun', [EXTRACTOR, path], { + encoding: 'utf8', + timeout: 30_000, + cwd: resolve(__dirname, '../..'), + }); + if (result.status !== 0) throw new Error(result.stderr ?? 'extractor failed'); + return JSON.parse(result.stdout) as Record; +} + +describe('rive-extract-schema', () => { + let schema: ReturnType; + + beforeAll(() => { + schema = extract(REWARDS_RIV); + }); + + test('extracts artboards', () => { + expect(schema.artboards).toContain('Main'); + expect(schema.defaultArtboard).toBe('Main'); + }); + + test('extracts state machines per artboard', () => { + expect((schema.stateMachines as Record).Main).toContain( + 'State Machine 1' + ); + }); + + test('extracts viewModels', () => { + expect( + (schema.viewModels as Record).Rewards + ).toBeDefined(); + }); + + test('resolves nested viewModel references', () => { + const vms = schema.viewModels as Record>; + expect(vms.Rewards!.Coin).toBe('viewModel:Item_Icon_Value'); + }); + + test('extracts primitive property types', () => { + const vms = schema.viewModels as Record>; + expect(vms.Item_Icon_Value!.Item_Value).toBe('number'); + expect(vms.Energy_Bar!.Bar_Color).toBe('color'); + }); + + test('extracts enum values as pipe-separated string', () => { + const databinding = extract( + resolve(__dirname, '../../example/assets/rive/databinding.riv') + ); + const vms = databinding.viewModels as Record< + string, + Record + >; + expect(vms.Person!.favourite_pet).toMatch(/^enum:/); + expect(vms.Person!.favourite_pet).toContain('chipmunk'); + expect(vms.Person!.favourite_pet).toContain('dog'); + }); +}); diff --git a/scripts/__tests__/rive-gen-types.test.ts b/scripts/__tests__/rive-gen-types.test.ts new file mode 100644 index 00000000..1175d6a9 --- /dev/null +++ b/scripts/__tests__/rive-gen-types.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect, beforeAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { resolve } from 'path'; +import { readFileSync, existsSync } from 'fs'; + +const GENERATOR = resolve(__dirname, '../rive-gen-types.ts'); +const REWARDS_RIV = resolve(__dirname, '../../example/assets/rive/rewards.riv'); +const OUT_DTS = `${REWARDS_RIV}.d.ts`; + +describe('rive-gen-types', () => { + beforeAll(() => { + const result = spawnSync('bun', [GENERATOR, REWARDS_RIV], { + encoding: 'utf8', + timeout: 30_000, + cwd: resolve(__dirname, '../..'), + }); + if (result.status !== 0) + throw new Error(result.stderr ?? 'generator failed'); + }); + + test('generates .riv.d.ts next to source file', () => { + expect(existsSync(OUT_DTS)).toBe(true); + }); + + test('generated file declares a RiveAsset default export', () => { + const content = readFileSync(OUT_DTS, 'utf8'); + expect(content).toContain('declare const asset: RiveAsset<'); + expect(content).toContain('export default asset'); + }); + + test('generated file contains artboard types', () => { + const content = readFileSync(OUT_DTS, 'utf8'); + expect(content).toContain("'Main'"); + }); + + test('generated file contains viewModel types', () => { + const content = readFileSync(OUT_DTS, 'utf8'); + expect(content).toContain('Rewards: {'); + expect(content).toContain("'viewModel:Item_Icon_Value'"); + }); + + test('generated file has eslint-disable header', () => { + const content = readFileSync(OUT_DTS, 'utf8'); + expect(content).toContain('eslint-disable'); + }); +}); diff --git a/scripts/rive-extract-schema.ts b/scripts/rive-extract-schema.ts new file mode 100644 index 00000000..6e14816f --- /dev/null +++ b/scripts/rive-extract-schema.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env bun +/** + * Extracts artboard names and state machines from a .riv file and prints JSON to stdout. + * + * Usage: + * bun scripts/rive-extract-schema.ts path/to/file.riv + * bun scripts/rive-extract-schema.ts https://example.com/file.riv + * + * Output: { "artboards": ["Main", "Intro"], "stateMachines": { "Main": ["State Machine 1"], "Intro": ["Idle"] } } + */ + +import { readFileSync } from 'fs'; +import { RuntimeLoader } from '@rive-app/canvas'; + +// noUncheckedIndexedAccess: process.argv destructuring yields string | undefined +const input: string | undefined = process.argv[2]; + +// @rive-app/canvas checks for browser globals during WASM initialisation. +// We only call file-inspection APIs (no rendering), so minimal shims are enough. +(globalThis as any).document = { + createElement: () => ({ getContext: () => null }), +}; +(globalThis as any).Image = class {}; + +// The WASM runtime prints warnings (e.g. "No WebGL support") via console. +// Redirect them to stderr so our JSON output stays clean. +console.log = (...args: unknown[]) => + process.stderr.write(args.join(' ') + '\n'); +console.warn = (...args: unknown[]) => + process.stderr.write(args.join(' ') + '\n'); + +// Catch errors from microtasks/unhandled rejections that would otherwise cause +// a silent exit with code 0 on some Bun versions (e.g. when img.la() throws in +// a queueMicrotask callback because WebGL is unavailable on Linux CI). +process.on('uncaughtException', (err) => { + process.stderr.write(`uncaughtException: ${err.stack ?? err.message}\n`); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + process.stderr.write(`unhandledRejection: ${reason}\n`); + process.exit(1); +}); + +if (!input) { + process.stderr.write('Usage: bun rive-extract-schema.ts \n'); + process.exit(1); +} +const source: string = input; + +async function loadBytes(source: string): Promise { + if (source.startsWith('http://') || source.startsWith('https://')) { + const res = await fetch(source); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${source}`); + return new Uint8Array(await res.arrayBuffer()); + } + return new Uint8Array(readFileSync(source)); +} + +async function main() { + const bytes = await loadBytes(source); + const runtime = await RuntimeLoader.awaitInstance(); + + // On headless Linux (no WebGL) the WASM image-load counter (aa.total/aa.loaded) + // never reaches its target because image texture creation silently fails, so + // load() never resolves. We patch makeRenderImage to wrap img.decode so that + // img.la() is always called after decode — a safe no-op if WebGL already fired + // it, but unblocks the Promise when WebGL is absent. + const origMRI = (runtime.renderFactory as any).makeRenderImage.bind( + runtime.renderFactory + ); + (runtime.renderFactory as any).makeRenderImage = function () { + const img = origMRI(); + if ( + img && + typeof (img as any).la === 'function' && + typeof (img as any).decode === 'function' + ) { + const origDecode = (img as any).decode.bind(img); + (img as any).decode = function (imgBytes: Uint8Array) { + origDecode(imgBytes); // fires la() internally if WebGL succeeds + // Defer la() to a microtask so it fires *after* the WASM's K() returns and + // assigns H. Calling la() synchronously inside K() would resolve the Promise + // with H=null (K hasn't returned yet), producing an empty file. + queueMicrotask(() => (img as any).la()); + }; + } + return img; + }; + + // CustomFileAssetLoader handles embedded assets so the runtime resolves load(). + // For fonts: decode() loads font bytes (no WebGL needed). + // For images: decode() triggers our patched img.decode → img.la() unblocks. + const assetLoader = new (runtime as any).CustomFileAssetLoader({ + loadContents: (asset: any, embeddedBytes: Uint8Array) => { + if (embeddedBytes?.length && asset?.decode) { + asset.decode(embeddedBytes); + } + return true; + }, + }); + + const riveFile = await runtime.load(bytes, assetLoader, false); + + const artboards: string[] = []; + const stateMachines: Record = {}; + for (let i = 0; i < riveFile.artboardCount(); i++) { + const artboard = riveFile.artboardByIndex(i); + artboards.push(artboard.name); + const sms: string[] = []; + for (let j = 0; j < artboard.stateMachineCount(); j++) { + sms.push(artboard.stateMachineByIndex(j).name); + } + stateMachines[artboard.name] = sms; + } + + const viewModels: Record> = {}; + const vmCount = (riveFile as any).viewModelCount() as number; + for (let i = 0; i < vmCount; i++) { + const vm = (riveFile as any).viewModelByIndex(i); + const properties = vm.getProperties() as Array<{ + name: string; + type: string; + }>; + // Create a blank instance to resolve viewModel property references + const inst = vm.instance?.() as any; + const props: Record = {}; + for (const p of properties) { + if (p.type === 'viewModel' && inst) { + try { + const nested = inst.viewModel?.(p.name); + const refName = nested?.getViewModelName?.(); + props[p.name] = refName ? `viewModel:${refName}` : 'viewModel'; + } catch { + props[p.name] = 'viewModel'; + } + } else if (p.type === 'enumType' && inst) { + try { + const ep = inst.enum?.(p.name); + const values: string[] = ep?.values ?? []; + props[p.name] = + values.length > 0 ? `enum:${values.join('|')}` : 'enum'; + } catch { + props[p.name] = 'enum'; + } + } else { + props[p.name] = p.type; + } + } + viewModels[vm.name] = props; + } + + const defaultArtboard = artboards[0] ?? ''; + process.stdout.write( + JSON.stringify( + { artboards, defaultArtboard, stateMachines, viewModels }, + null, + 2 + ) + '\n' + ); +} + +main().catch((err: Error) => { + process.stderr.write(err.message + '\n'); + process.exit(1); +}); diff --git a/scripts/rive-gen-types.ts b/scripts/rive-gen-types.ts new file mode 100644 index 00000000..156ce065 --- /dev/null +++ b/scripts/rive-gen-types.ts @@ -0,0 +1,335 @@ +#!/usr/bin/env bun +/** + * Generates .riv.d.ts declaration files so that `import asset from './file.riv'` + * is automatically typed in TypeScript without any extra imports. + * + * Usage: + * bun scripts/rive-gen-types.ts # writes .riv.d.ts next to the source + * bun scripts/rive-gen-types.ts --out # write a standalone schema .ts instead + * bun scripts/rive-gen-types.ts --all # generate for every .riv in a directory + * + * After generation, TypeScript resolves the .riv.d.ts automatically: + * import gameRiv from './assets/game.riv'; // typed as RiveAsset + * const file = await RiveFileFactory.fromSource(gameRiv); // TypedRiveFile — T inferred + */ + +import { + readFileSync, + writeFileSync, + mkdirSync, + readdirSync, + statSync, +} from 'fs'; +import { dirname, resolve, basename, extname } from 'path'; +import { RuntimeLoader } from '@rive-app/canvas'; + +// Browser shims required by the @rive-app/canvas WASM runtime. +(globalThis as any).document = { + createElement: () => ({ getContext: () => null }), +}; +(globalThis as any).Image = class {}; + +// Silence WASM warnings (e.g. "No WebGL support") so they don't pollute output. +console.log = (...args: unknown[]) => + process.stderr.write(args.join(' ') + '\n'); +console.warn = (...args: unknown[]) => + process.stderr.write(args.join(' ') + '\n'); + +interface Schema { + artboards: string[]; + defaultArtboard: string; + stateMachines: Record; + viewModels: Record>; +} + +let runtimeReady: Promise | null = null; + +async function getRuntime(): Promise { + if (!runtimeReady) { + runtimeReady = RuntimeLoader.awaitInstance().then((runtime) => { + // On headless Linux (no WebGL) the image-load counter (aa.total/aa.loaded) + // never resolves. Wrap img.decode to fire img.la() via queueMicrotask after + // K() returns so the Promise resolves with the actual file, not null. + const origMRI = (runtime.renderFactory as any).makeRenderImage.bind( + runtime.renderFactory + ); + (runtime.renderFactory as any).makeRenderImage = function () { + const img = origMRI(); + if ( + img && + typeof (img as any).la === 'function' && + typeof (img as any).decode === 'function' + ) { + const origDecode = (img as any).decode.bind(img); + (img as any).decode = function (imgBytes: Uint8Array) { + origDecode(imgBytes); + queueMicrotask(() => (img as any).la()); + }; + } + return img; + }; + return runtime; + }); + } + return runtimeReady; +} + +async function extractSchema(input: string): Promise { + const bytes = + input.startsWith('http://') || input.startsWith('https://') + ? new Uint8Array(await (await fetch(input)).arrayBuffer()) + : new Uint8Array(readFileSync(input)); + + const runtime = await getRuntime(); + + const assetLoader = new (runtime as any).CustomFileAssetLoader({ + loadContents: (asset: any, embeddedBytes: Uint8Array) => { + if (embeddedBytes?.length && asset?.decode) { + asset.decode(embeddedBytes); + } + return true; + }, + }); + + const riveFile = await runtime.load(bytes, assetLoader, false); + + const artboards: string[] = []; + const stateMachines: Record = {}; + for (let i = 0; i < riveFile.artboardCount(); i++) { + const artboard = riveFile.artboardByIndex(i); + artboards.push(artboard.name); + const sms: string[] = []; + for (let j = 0; j < artboard.stateMachineCount(); j++) { + sms.push(artboard.stateMachineByIndex(j).name); + } + stateMachines[artboard.name] = sms; + } + + const viewModels: Record> = {}; + const vmCount = (riveFile as any).viewModelCount() as number; + for (let i = 0; i < vmCount; i++) { + const vm = (riveFile as any).viewModelByIndex(i); + const properties = vm.getProperties() as Array<{ + name: string; + type: string; + }>; + const inst = vm.instance?.() as any; + const props: Record = {}; + for (const p of properties) { + if (p.type === 'viewModel' && inst) { + try { + const nested = inst.viewModel?.(p.name); + const refName = nested?.getViewModelName?.(); + props[p.name] = refName ? `viewModel:${refName}` : 'viewModel'; + } catch { + props[p.name] = 'viewModel'; + } + } else if (p.type === 'enumType' && inst) { + try { + const ep = inst.enum?.(p.name); + const values: string[] = ep?.values ?? []; + props[p.name] = + values.length > 0 ? `enum:${values.join('|')}` : 'enum'; + } catch { + props[p.name] = 'enum'; + } + } else { + props[p.name] = p.type; + } + } + viewModels[vm.name] = props; + } + + return { + artboards, + defaultArtboard: artboards[0] ?? '', + stateMachines, + viewModels, + }; +} + +// With prettier quoteProps:"consistent", if any key in an object needs quotes, all get quotes. +const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +const needsQuote = (s: string) => !IDENTIFIER_RE.test(s); +const quoteKey = (s: string, forceQuote: boolean) => + forceQuote || needsQuote(s) ? `'${s}'` : s; + +function smRecord(stateMachines: Record): string { + const keys = Object.keys(stateMachines); + const force = keys.some(needsQuote); + return Object.entries(stateMachines) + .map(([ab, sms]) => { + const union = sms.length ? sms.map((s) => `'${s}'`).join(' | ') : 'never'; + return ` ${quoteKey(ab, force)}: ${union};`; + }) + .join('\n'); +} + +function vmRecord(viewModels: Record>): string { + const vmKeys = Object.keys(viewModels); + const forceVmKeys = vmKeys.some(needsQuote); + return Object.entries(viewModels) + .map(([vmName, props]) => { + const propKeys = Object.keys(props); + const forcePropKeys = propKeys.some(needsQuote); + const propLines = Object.entries(props) + .map( + ([propName, propType]) => + ` ${quoteKey(propName, forcePropKeys)}: '${propType}';` + ) + .join('\n'); + return ` ${quoteKey(vmName, forceVmKeys)}: {\n${propLines}\n };`; + }) + .join('\n'); +} + +function schemaBody(schema: Schema): string { + const vmSection = + Object.keys(schema.viewModels).length > 0 + ? `\n viewModels: {\n${vmRecord(schema.viewModels)}\n };` + : ''; + return `\ + artboards: ${schema.artboards.map((a) => `'${a}'`).join(' | ')}; + defaultArtboard: '${schema.defaultArtboard}'; + stateMachines: { +${smRecord(schema.stateMachines)} + };${vmSection}`; +} + +function dtsContent(input: string, schema: Schema): string { + return `\ +// Generated by rive-gen-types — do not edit manually. @generated +// eslint-disable +// Source: ${basename(input)} +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ +${schemaBody(schema)} +}>; +export default asset; +`; +} + +function standaloneContent( + input: string, + typeName: string, + schema: Schema +): string { + return `\ +// Generated by rive-gen-types — do not edit manually. +// Source: ${input} +import type { RiveFileSchema } from '@rive-app/react-native'; + +export type ${typeName} = RiveFileSchema & { +${schemaBody(schema)} +}; +`; +} + +async function generate( + input: string, + outPath: string, + mode: 'dts' | 'standalone', + typeName?: string +) { + let schema: Schema; + try { + schema = await extractSchema(input); + } catch (err) { + process.stderr.write( + `Failed to extract schema from ${input}: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); + } + + if (!schema.artboards?.length) { + process.stderr.write(`No artboards found in ${input}.\n`); + process.exit(1); + } + + const content = + mode === 'dts' + ? dtsContent(input, schema) + : standaloneContent(input, typeName!, schema); + + mkdirSync(dirname(outPath), { recursive: true }); + writeFileSync(outPath, content, 'utf8'); + process.stdout.write(`Written: ${outPath}\n`); +} + +function findRivFiles(dir: string): string[] { + const results: string[] = []; + for (const entry of readdirSync(dir)) { + const full = resolve(dir, entry); + if (statSync(full).isDirectory()) { + results.push(...findRivFiles(full)); + } else if (extname(entry) === '.riv') { + results.push(full); + } + } + return results; +} + +// --- CLI --- + +async function main() { + // noUncheckedIndexedAccess: slice gives string[], index access gives string | undefined + const args: string[] = process.argv.slice(2); + + if (args[0] === '--all') { + const dir: string | undefined = args[1]; + if (!dir) { + process.stderr.write('Usage: rive-gen-types --all \n'); + process.exit(1); + } + const files = findRivFiles(resolve(process.cwd(), dir)); + if (!files.length) { + process.stderr.write(`No .riv files found in ${dir}\n`); + process.exit(1); + } + for (const file of files) { + await generate(file, `${file}.d.ts`, 'dts'); + } + return; + } + + if (!args.length || args[0]!.startsWith('--')) { + process.stderr.write( + 'Usage:\n' + + ' rive-gen-types # writes .riv.d.ts\n' + + ' rive-gen-types --out # standalone schema .ts\n' + + ' rive-gen-types --all # all .riv files in dir\n' + ); + process.exit(1); + } + + const input = args[0]!; + const outIdx = args.indexOf('--out'); + + if (outIdx !== -1) { + // Standalone mode: generate a named schema type, not a .d.ts + const outPath = resolve(process.cwd(), args[outIdx + 1]!); + const baseName = basename(input, '.riv').replace(/[^a-zA-Z0-9]/g, '_'); + const nameIdx = args.indexOf('--name'); + const typeName = + nameIdx !== -1 + ? args[nameIdx + 1]! + : baseName.charAt(0).toUpperCase() + baseName.slice(1) + 'Schema'; + await generate(input, outPath, 'standalone', typeName); + } else { + if (input.startsWith('http://') || input.startsWith('https://')) { + process.stderr.write( + `Error: URL inputs require --out to specify the output path.\n` + + ` Example: rive-gen-types ${input} --out ./assets/file.riv.d.ts\n` + ); + process.exit(1); + } + // Default: write .riv.d.ts next to the source file + const absInput = resolve(process.cwd(), input); + await generate(input, `${absInput}.d.ts`, 'dts'); + } +} + +main().catch((err: Error) => { + process.stderr.write(err.message + '\n'); + process.exit(1); +}); diff --git a/src/__tests__/typed-rive.test-d.ts b/src/__tests__/typed-rive.test-d.ts new file mode 100644 index 00000000..b62232ef --- /dev/null +++ b/src/__tests__/typed-rive.test-d.ts @@ -0,0 +1,274 @@ +import { expectType, expectError, expectAssignable } from 'tsd'; +import type { TypedRiveFile, RiveAsset } from '../../src/core/TypedRiveFile'; +import type { + TypedViewModelInstance, + TypedViewModelListProperty, + TypedViewModelEnumProperty, + UntypedViewModelInstance, +} from '../../src/core/TypedViewModelInstance'; +import type { + ViewModelInstance, + ViewModelNumberProperty, + ViewModelTriggerProperty, + ViewModelBooleanProperty, +} from '../../src/specs/ViewModel.nitro'; +import type { UseRivePropertyResult } from '../../src/types'; +import { useRiveNumber } from '../../src/hooks/useRiveNumber'; +import type { RiveViewProps } from '../../src/core/RiveView'; +import gradientBorderRiv from '../../example/assets/rive/GradientBorder.riv'; +import blinkoRiv from '../../example/assets/rive/blinko.riv'; + +// Infer schemas from the generated .riv.d.ts assets +type GradientBorderSchema = typeof gradientBorderRiv extends RiveAsset + ? T + : never; +type BlinkoSchema = typeof blinkoRiv extends RiveAsset ? T : never; + +declare const gradientFile: TypedRiveFile; +declare const blinkoFile: TypedRiveFile; +declare const untypedFile: TypedRiveFile; + +// --- TypedRiveFile assignability --- + +// Typed files are assignable to the untyped base (backward compat) +expectAssignable(gradientFile); +expectAssignable(blinkoFile); + +// --- Single-artboard: artboardName constraints --- + +expectAssignable>({ + file: gradientFile, + artboardName: 'Layout', +}); + +// Wrong artboard name must error +expectError>({ + file: gradientFile, + artboardName: 'NotAnArtboard', +}); + +// --- Single-artboard: stateMachineName constraints --- + +expectAssignable>({ + file: gradientFile, + stateMachineName: 'State', +}); + +// Wrong SM name must error +expectError>({ + file: gradientFile, + stateMachineName: 'NonexistentSM', +}); + +// --- Multi-artboard: default artboard (A not specified) --- +// When artboardName is not specified, A defaults to defaultArtboard ('Main'). +// artboardName must be 'Main' (or undefined), stateMachineName constrained to Main's SMs. + +expectAssignable>({ + file: blinkoFile, + artboardName: 'Main', + stateMachineName: 'State Machine 1', +}); + +expectAssignable>({ + file: blinkoFile, + stateMachineName: 'State Machine 1', +}); + +// Non-default artboard without explicit A type parameter must error +expectError>({ + file: blinkoFile, + artboardName: 'StoreItem', +}); + +// Invalid SM on default artboard must error +expectError>({ + file: blinkoFile, + stateMachineName: 'Nonexistent SM', +}); + +// --- Multi-artboard: explicit non-default artboard (A specified) --- +// Specifying A constrains artboardName and stateMachineName to that artboard. + +expectAssignable>({ + file: blinkoFile, + artboardName: 'StoreItem', + stateMachineName: 'State Machine 1', +}); + +// Wrong artboard when A = 'StoreItem' must error +expectError>({ + file: blinkoFile, + artboardName: 'Puck', +}); + +// Wrong SM when A = 'StoreItem' must error +expectError>({ + file: blinkoFile, + artboardName: 'StoreItem', + stateMachineName: 'Nonexistent SM', +}); + +// --- Untyped file: accepts any string (backward compat) --- + +expectAssignable({ + file: untypedFile, + artboardName: 'anything', + stateMachineName: 'anything', +}); + +// --- RiveAsset branding --- + +declare const gradientAsset: RiveAsset; + +// RiveAsset is a branded number +expectType( + gradientAsset +); + +// RiveAsset with wrong schema is not assignable to a different typed asset +expectError>(gradientAsset); + +// ============================================================ +// TypedViewModelInstance +// ============================================================ + +// Blinko storeVM: xbuttonClick:trigger, multiplierValue:number, +// storeOpen:boolean, 'property of pegVM':viewModel:pegVM +// pegVM: blink:trigger, multiplierValue:number, pegType:enumType + +type StoreVMInstance = TypedViewModelInstance; +type PegVMInstance = TypedViewModelInstance; + +declare const storeVM: StoreVMInstance; + +// --- Direct property access --- + +// Valid number property +expectAssignable( + storeVM.numberProperty('multiplierValue') +); + +// Valid trigger property +expectAssignable( + storeVM.triggerProperty('xbuttonClick') +); + +// Valid boolean property +expectAssignable( + storeVM.booleanProperty('storeOpen') +); + +// Wrong kind: xbuttonClick is 'trigger', not 'number' +expectError(storeVM.numberProperty('xbuttonClick')); + +// Nonexistent property name must error +expectError(storeVM.numberProperty('doesNotExist')); + +// --- Nested viewModel access --- + +// Accessing a viewModel reference returns the correct typed instance +expectAssignable( + storeVM.viewModel('property of pegVM') +); + +// Non-viewModel property name is rejected for viewModel() +expectError(storeVM.viewModel('xbuttonClick')); + +// Nonexistent path is rejected +expectError(storeVM.viewModel('notAProperty')); + +// --- Chained nested access --- + +// storeVM → pegVM → pegVM.numberProperty('multiplierValue') +expectAssignable( + storeVM.viewModel('property of pegVM')?.numberProperty('multiplierValue') +); + +// storeVM → pegVM → wrong property kind errors +expectError( + storeVM.viewModel('property of pegVM')?.numberProperty('blink') +); + +// storeVM → pegVM → nonexistent property errors +expectError( + storeVM.viewModel('property of pegVM')?.stringProperty('doesNotExist') +); + +// --- Enum property --- + +// pegVM.pegType is 'enum:normal|multiplier' — returns typed enum property +expectAssignable< + TypedViewModelEnumProperty<'normal' | 'multiplier'> | undefined +>(storeVM.viewModel('property of pegVM')?.enumProperty('pegType')); + +// The enum value type is exactly 'normal' | 'multiplier' +expectType | undefined>( + storeVM.viewModel('property of pegVM')?.enumProperty('pegType') +); + +// Non-enum property rejected for enumProperty() +expectError(storeVM.enumProperty('xbuttonClick')); + +// Nonexistent property rejected +expectError(storeVM.enumProperty('doesNotExist')); + +// --- List property --- + +// storeVM.items is a 'list' — returns a typed list property +expectAssignable | undefined>( + storeVM.listProperty('items') +); + +// Non-list property rejected for listProperty() +expectError(storeVM.listProperty('xbuttonClick')); + +// List element is a union of all file ViewModels (any one of them) +type AnyBlinkoVM = TypedViewModelInstance< + BlinkoSchema, + Extract +>; +declare const list: TypedViewModelListProperty; +expectAssignable>(list.getInstanceAtAsync(0)); + +// ============================================================ +// useRiveNumber +// ============================================================ + +declare const untypedInstance: UntypedViewModelInstance; +declare const plainInstance: ViewModelInstance; + +// Typed overload: valid direct number path → result typed as number +expectType>( + useRiveNumber('multiplierValue', storeVM) +); + +// Typed overload: valid nested number path (nested ViewModel) +expectType>( + useRiveNumber('property of pegVM/multiplierValue', storeVM) +); + +// Typed overload: wrong kind — xbuttonClick is a trigger, not a number +expectError(useRiveNumber('xbuttonClick', storeVM)); + +// Typed overload: nonexistent path +expectError(useRiveNumber('doesNotExist', storeVM)); + +// Typed overload: nested path with wrong property kind +expectError(useRiveNumber('property of pegVM/blink', storeVM)); + +// Typed instance rejected by untyped overload → forces typed overload → wrong path errors +expectError(useRiveNumber('multiplierValue' as string, storeVM)); + +// Untyped overload: plain ViewModelInstance accepts any string path +expectType>( + useRiveNumber('any/path', untypedInstance) +); + +// Untyped overload: plain ViewModelInstance (no brand) also accepted +expectType>( + useRiveNumber('any/path', plainInstance) +); + +// No instance: falls back to untyped overload, still returns number result +expectType>(useRiveNumber('multiplierValue')); diff --git a/src/core/RiveFile.ts b/src/core/RiveFile.ts index ba8e873f..1c991abe 100644 --- a/src/core/RiveFile.ts +++ b/src/core/RiveFile.ts @@ -1,8 +1,6 @@ import { NitroModules } from 'react-native-nitro-modules'; -import type { - RiveFile, - RiveFileFactory as RiveFileFactoryInternal, -} from '../specs/RiveFile.nitro'; +import type { RiveFileFactory as RiveFileFactoryInternal } from '../specs/RiveFile.nitro'; +import type { RiveAsset, RiveFileSchema, TypedRiveFile } from './TypedRiveFile'; import { Image } from 'react-native'; import type { ResolvedReferencedAssets } from './ReferencedAssets'; @@ -15,76 +13,52 @@ const RiveFileInternal = * Provides static methods to load Rive files from URLs, resources, or raw bytes. */ export namespace RiveFileFactory { - /** - * Creates a RiveFile instance from a URL. - * @param url - The URL of the Rive (.riv) file - * @param loadCdn - Whether to load from CDN (default: true) - * @returns Promise that resolves to a RiveFile instance - */ - export async function fromURL( + export async function fromURL( url: string, referencedAssets: ResolvedReferencedAssets | undefined, loadCdn: boolean = true - ): Promise { + ): Promise> { return RiveFileInternal.fromURL( url, loadCdn, referencedAssets ? { data: referencedAssets } : undefined - ); + ) as Promise>; } - /** - * Creates a RiveFile instance from a local file path URL. - * @param pathURL - The local file path of the Rive (.riv) file - * @param loadCdn - Whether to load from CDN (default: true) - * @returns Promise that resolves to a RiveFile instance - */ - export async function fromFileURL( + export async function fromFileURL( fileURL: string, referencedAssets: ResolvedReferencedAssets | undefined = undefined, loadCdn: boolean = true - ): Promise { + ): Promise> { return RiveFileInternal.fromFileURL( fileURL, loadCdn, referencedAssets ? { data: referencedAssets } : undefined - ); + ) as Promise>; } - /** - * Creates a RiveFile instance from a local resource. - * @param resource - The name of the local resource - * @param loadCdn - Whether to load from CDN (default: true) - * @returns Promise that resolves to a RiveFile instance - */ - export async function fromResource( + export async function fromResource( resource: string, referencedAssets: ResolvedReferencedAssets | undefined, loadCdn: boolean = true - ): Promise { + ): Promise> { return RiveFileInternal.fromResource( resource, loadCdn, referencedAssets ? { data: referencedAssets } : undefined - ); + ) as Promise>; } - /** - * Creates a RiveFile instance from raw bytes. - * @param bytes - The raw bytes of the Rive (.riv) file - * @param loadCdn - Whether to load from CDN (default: true) - * @returns Promise that resolves to a RiveFile instance - */ - export async function fromBytes( + export async function fromBytes( bytes: ArrayBuffer, referencedAssets: ResolvedReferencedAssets | undefined, loadCdn: boolean = true - ): Promise { + ): Promise> { return RiveFileInternal.fromBytes( bytes, loadCdn, referencedAssets ? { data: referencedAssets } : undefined - ); + ) as Promise>; } /** @@ -109,11 +83,21 @@ export namespace RiveFileFactory { * config.resolver.assetExts = [...config.resolver.assetExts, 'riv']; * ``` */ + export async function fromSource( + source: RiveAsset, + referencedAssets: ResolvedReferencedAssets | undefined, + loadCdn?: boolean + ): Promise>; export async function fromSource( + source: number | { uri: string }, + referencedAssets: ResolvedReferencedAssets | undefined, + loadCdn?: boolean + ): Promise; + export async function fromSource( source: number | { uri: string }, referencedAssets: ResolvedReferencedAssets | undefined, loadCdn: boolean = true - ): Promise { + ): Promise> { const assetID = typeof source === 'number' ? source : null; const sourceURI = typeof source === 'object' ? source.uri : null; @@ -130,16 +114,24 @@ export namespace RiveFileFactory { try { // handle http address and dev server if (assetURI.match(/https?:\/\//)) { - return RiveFileFactory.fromURL(assetURI, referencedAssets, loadCdn); + return RiveFileFactory.fromURL(assetURI, referencedAssets, loadCdn); } // handle iOS bundled asset if (assetURI.match(/file:\/\//)) { - return RiveFileFactory.fromFileURL(assetURI, referencedAssets, loadCdn); + return RiveFileFactory.fromFileURL( + assetURI, + referencedAssets, + loadCdn + ); } // handle Android bundled asset or resource name uri - return RiveFileFactory.fromResource(assetURI, referencedAssets, loadCdn); + return RiveFileFactory.fromResource( + assetURI, + referencedAssets, + loadCdn + ); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); diff --git a/src/core/RiveView.tsx b/src/core/RiveView.tsx index f7bd16f3..14df1211 100644 --- a/src/core/RiveView.tsx +++ b/src/core/RiveView.tsx @@ -3,48 +3,35 @@ import { NitroRiveView } from './NitroRiveViewComponent'; import { RiveErrorType, type RiveError } from './Errors'; import { callDispose } from './callDispose'; import type { RiveViewRef } from '../index'; +import type { RiveFileSchema, TypedRiveFile } from './TypedRiveFile'; -export interface RiveViewProps extends Omit< - ComponentProps, - 'onError' +type NitroRiveViewProps = ComponentProps; + +export interface RiveViewProps< + T extends RiveFileSchema = RiveFileSchema, + A extends T['artboards'] = T['defaultArtboard'], +> extends Omit< + NitroRiveViewProps, + 'onError' | 'file' | 'artboardName' | 'stateMachineName' > { onError?: (error: RiveError) => void; + file: TypedRiveFile; + /** Name of the artboard to display. When using a generated schema, only valid artboard names are accepted. */ + artboardName?: A; + /** + * Name of the state machine to play. + * Constrained to the selected artboard's state machines, or the default artboard's if none is specified. + */ + stateMachineName?: T['stateMachines'][A]; } const defaultOnError = (error: RiveError) => console.error(`[${RiveErrorType[error.type]}] ${error.message}`); -/** - * RiveView is a React Native component that renders Rive graphics. - * It provides a seamless way to display and control Rive graphics in your app. - * - * @example - * ```tsx - * - * ``` - * - * @property {RiveFile} file - The Rive file to be displayed - * @property {string} [artboardName] - Name of the artboard to display from the Rive file - * @property {string} [stateMachineName] - Name of the state machine to play - * @property {ViewModelInstance | DataBindMode | DataBindByName} [dataBind] - Data binding configuration for the state machine, defaults to DataBindMode.Auto - * @property {boolean} [autoPlay=true] - Whether to automatically start playing the state machine - * @property {Alignment} [alignment] - How the Rive graphic should be aligned within its container - * @property {Fit} [fit] - How the Rive graphic should fit within its container - * @property {Object} [style] - React Native style object for container customization - * @property {(error: RiveError) => void} [onError] - Callback function that is called when an error occurs - * - * The component also exposes methods for controlling playback: - * - play(): Starts playing the Rive graphic - * - pause(): Pauses the Rive graphic - */ -export function RiveView(props: RiveViewProps) { +export function RiveView< + T extends RiveFileSchema = RiveFileSchema, + A extends T['artboards'] = T['defaultArtboard'], +>(props: RiveViewProps) { const { onError, hybridRef: userHybridRef, ...rest } = props; const wrappedOnError = onError ?? defaultOnError; const viewRef = useRef(null); diff --git a/src/core/TypedRiveFile.ts b/src/core/TypedRiveFile.ts new file mode 100644 index 00000000..82ae93f1 --- /dev/null +++ b/src/core/TypedRiveFile.ts @@ -0,0 +1,64 @@ +import type { RiveFile } from '../specs/RiveFile.nitro'; + +/** + * Describes the statically-known shape of a .riv file. + * Extend this interface via generated schema files to get type-safe artboard names. + * + * @example Generated by: yarn rive-gen-types path/to/file.riv + * ```ts + * export type MySchema = RiveFileSchema & { + * artboards: 'Main' | 'Intro' | 'GameOver'; + * }; + * ``` + */ +export interface RiveFileSchema { + artboards: string; + defaultArtboard: string; + stateMachines: Record; + viewModels: Record>; +} + +/** + * A Metro asset (number) branded with a schema type `T`. + * The `__riveSchema` field is purely phantom — it never exists at runtime. + * + * Created automatically when you `import asset from './file.riv'` and a + * corresponding `file.riv.d.ts` has been generated by `yarn rive-gen-types`. + * + * Pass to `RiveFileFactory.fromSource(asset)` — TypeScript infers `T` automatically. + */ +export type RiveAsset = number & { + readonly __riveSchema?: T; +}; + +/** + * Extracts the RiveFileSchema from a RiveAsset, TypedRiveFile, or a bare RiveFileSchema. + * + * @example + * import rewardsRiv from './rewards.riv'; + * type Schema = SchemaOf; // RewardsSchema + */ +export type SchemaOf = T extends { + readonly __riveSchema?: infer S extends RiveFileSchema; +} + ? S + : T extends { readonly __schema?: infer S extends RiveFileSchema } + ? S + : T extends RiveFileSchema + ? T + : never; + +/** + * A RiveFile branded with a schema type `T`. + * The `__schema` field is purely a phantom type — it never exists at runtime. + * + * Accepts either a `RiveFileSchema` or a `RiveAsset` (i.e. `typeof myRiv`). + * + * Obtain one via `RiveFileFactory.fromURL(...)` or + * `RiveFileFactory.fromSource(typedAsset)`. + */ +export type TypedRiveFile< + T extends RiveFileSchema | RiveAsset = RiveFileSchema, +> = RiveFile & { + readonly __schema?: SchemaOf; +}; diff --git a/src/core/TypedViewModelInstance.ts b/src/core/TypedViewModelInstance.ts new file mode 100644 index 00000000..1230b67c --- /dev/null +++ b/src/core/TypedViewModelInstance.ts @@ -0,0 +1,222 @@ +import type { + ViewModelInstance, + ViewModelNumberProperty, + ViewModelStringProperty, + ViewModelBooleanProperty, + ViewModelColorProperty, + ViewModelTriggerProperty, + ViewModelEnumProperty, + ViewModelImageProperty, + ViewModelListProperty, +} from '../specs/ViewModel.nitro'; +import type { RiveAsset, RiveFileSchema, SchemaOf } from './TypedRiveFile'; + +/** + * A typed list property whose elements are ViewModelInstances from the same file. + * Elements can be any ViewModel defined in the file — the exact type is unknown + * until runtime, so the element type is a union of all file ViewModels. + */ +export interface TypedViewModelListProperty< + T extends RiveFileSchema, +> extends Omit { + getInstanceAtAsync( + index: number + ): Promise< + | TypedViewModelInstance> + | undefined + >; + /** @deprecated Use getInstanceAtAsync instead */ + getInstanceAt( + index: number + ): + | TypedViewModelInstance> + | undefined; +} + +/** Split a pipe-separated string literal into a union: 'a|b|c' → 'a' | 'b' | 'c' */ +type UnionFromPipe = S extends `${infer A}|${infer B}` + ? A | UnionFromPipe + : S; + +/** Extract the enum value union from a schema type string like 'enum:cat|dog|frog' */ +export type EnumValuesOf = S extends `enum:${infer V}` + ? UnionFromPipe + : never; + +/** + * A typed enum property whose value and setter are constrained to the specific enum values + * extracted from the .riv file schema. + */ +export interface TypedViewModelEnumProperty< + Values extends string = string, +> extends Omit< + ViewModelEnumProperty, + 'value' | 'getValueAsync' | 'set' | 'addListener' +> { + /** @deprecated Use getValueAsync (read) or set(value) (write) instead */ + value: Values; + getValueAsync(): Promise; + set(value: Values): void; + addListener(onChanged: (value: Values) => void): () => void; +} + +/** + * Property names whose type matches the given Kind. + * Use kind `'enum'` to match any enum property (stored as `'enum:val1|val2'` in the schema). + */ +export type VMPropsOfKind< + VM extends Record, + Kind extends string, +> = { + [K in keyof VM]: Kind extends 'enum' + ? VM[K] extends `enum:${string}` + ? K + : never + : VM[K] extends Kind + ? K + : never; +}[keyof VM] & + string; + +/** Extract the referenced ViewModel name from 'viewModel:SomeName' */ +export type VMRefName = + TypeStr extends `viewModel:${infer Name}` ? Name : never; + +/** Property names that are viewModel references (type = 'viewModel:*') */ +export type VMRefPropNames> = { + [K in keyof VM]: VM[K] extends `viewModel:${string}` ? K : never; +}[keyof VM] & + string; + +/** Resolved ViewModel name for a nested viewModel reference property */ +type VMRefNameResolved< + T extends RiveFileSchema, + VMName extends keyof T['viewModels'] & string, + P extends keyof T['viewModels'][VMName], +> = VMRefName & + Extract; + +type NestedPathsOfKind< + T extends RiveFileSchema, + VMName extends keyof T['viewModels'] & string, + Kind extends string, +> = { + [P in VMRefPropNames< + T['viewModels'][VMName] + >]: `${P}/${VMPropsOfKind], Kind>}`; +}[VMRefPropNames]; + +/** + * All valid property paths of a given kind, including one level of nested ViewModel paths. + * Direct paths: `'Price_Value'` + * Nested paths: `'Coin/Item_Value'` + */ +export type PathsOfKind< + T extends RiveFileSchema, + VMName extends keyof T['viewModels'] & string, + Kind extends string, +> = + | VMPropsOfKind + | NestedPathsOfKind; + +/** + * A ViewModelInstance typed to a specific ViewModel schema entry. + * Property accessor methods are constrained to valid property names. + * `.viewModel(path)` returns a TypedViewModelInstance for the referenced ViewModel. + * + * Obtain via `useViewModelInstance(typedFile, { viewModelName: 'MyVM' })`. + */ +export interface TypedViewModelInstance< + T extends RiveFileSchema, + VMName extends keyof T['viewModels'] & string, +> extends Omit< + ViewModelInstance, + | 'numberProperty' + | 'stringProperty' + | 'booleanProperty' + | 'colorProperty' + | 'triggerProperty' + | 'enumProperty' + | 'imageProperty' + | 'listProperty' + | 'viewModel' + | 'viewModelAsync' +> { + numberProperty( + path: VMPropsOfKind + ): ViewModelNumberProperty | undefined; + + stringProperty( + path: VMPropsOfKind + ): ViewModelStringProperty | undefined; + + booleanProperty( + path: VMPropsOfKind + ): ViewModelBooleanProperty | undefined; + + colorProperty( + path: VMPropsOfKind + ): ViewModelColorProperty | undefined; + + triggerProperty( + path: VMPropsOfKind + ): ViewModelTriggerProperty | undefined; + + enumProperty

>( + path: P + ): + | TypedViewModelEnumProperty> + | undefined; + + imageProperty( + path: VMPropsOfKind + ): ViewModelImageProperty | undefined; + + listProperty( + path: VMPropsOfKind + ): TypedViewModelListProperty | undefined; + + /** Access a nested ViewModel instance; return type is typed to the referenced ViewModel. */ + viewModel

>( + path: P + ): + | TypedViewModelInstance< + T, + VMRefName & keyof T['viewModels'] & string + > + | undefined; + + viewModelAsync

>( + path: P + ): Promise< + | TypedViewModelInstance< + T, + VMRefName & keyof T['viewModels'] & string + > + | undefined + >; + + /** Brand that prevents typed instances from matching untyped hook overloads. */ + readonly __vmBrand: [T, VMName]; +} + +/** + * A plain ViewModelInstance with no schema type information. + * Used in the untyped hook overloads — typed instances are intentionally + * excluded so TypeScript picks the typed overload when a schema is known. + */ +export type UntypedViewModelInstance = ViewModelInstance & { + __vmBrand?: never; +}; + +/** + * Convenience alias: infer the ViewModel instance type directly from a RiveAsset import. + * + * @example + * import rewardsRiv from './rewards.riv'; + * type RewardsInstance = TypedViewModelOf; + */ +export type TypedViewModelOf< + T extends RiveFileSchema | RiveAsset, + VMName extends Extract['viewModels'], string>, +> = TypedViewModelInstance, VMName>; diff --git a/src/hooks/useRiveBoolean.ts b/src/hooks/useRiveBoolean.ts index 657d9ad6..f6449aa0 100644 --- a/src/hooks/useRiveBoolean.ts +++ b/src/hooks/useRiveBoolean.ts @@ -4,6 +4,12 @@ import { } from '../specs/ViewModel.nitro'; import type { UseRivePropertyResult } from '../types'; import { useRiveProperty } from './useRiveProperty'; +import type { + PathsOfKind, + TypedViewModelInstance, + UntypedViewModelInstance, +} from '../core/TypedViewModelInstance'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; const getBooleanProperty = (vmi: ViewModelInstance, p: string) => vmi.booleanProperty(p); @@ -15,6 +21,17 @@ const getBooleanProperty = (vmi: ViewModelInstance, p: string) => * @param viewModelInstance - The ViewModelInstance containing the boolean property to operate on * @returns An object with the boolean value, a setter function, and an error if the property is not found */ +export function useRiveBoolean< + T extends RiveFileSchema, + N extends Extract, +>( + path: PathsOfKind, + viewModelInstance?: TypedViewModelInstance | null +): UseRivePropertyResult; +export function useRiveBoolean( + path: string, + viewModelInstance?: UntypedViewModelInstance | null +): UseRivePropertyResult; export function useRiveBoolean( path: string, viewModelInstance?: ViewModelInstance | null diff --git a/src/hooks/useRiveColor.ts b/src/hooks/useRiveColor.ts index bd6ce38e..f16ccfd8 100644 --- a/src/hooks/useRiveColor.ts +++ b/src/hooks/useRiveColor.ts @@ -5,6 +5,12 @@ import type { } from '../specs/ViewModel.nitro'; import { useRiveProperty } from './useRiveProperty'; import { RiveColor } from '../core/RiveColor'; +import type { + PathsOfKind, + TypedViewModelInstance, + UntypedViewModelInstance, +} from '../core/TypedViewModelInstance'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; const getColorProperty = (vmi: ViewModelInstance, p: string) => vmi.colorProperty(p); @@ -22,6 +28,17 @@ export interface UseRiveColorResult { * @param viewModelInstance - The ViewModelInstance containing the color property to operate on * @returns An object with the color value as RGBA, a setter function that accepts either RGBA or hex string, and an error if the property is not found */ +export function useRiveColor< + T extends RiveFileSchema, + N extends Extract, +>( + path: PathsOfKind, + viewModelInstance?: TypedViewModelInstance | null +): UseRiveColorResult; +export function useRiveColor( + path: string, + viewModelInstance?: UntypedViewModelInstance | null +): UseRiveColorResult; export function useRiveColor( path: string, viewModelInstance?: ViewModelInstance | null diff --git a/src/hooks/useRiveEnum.ts b/src/hooks/useRiveEnum.ts index ee5d67ba..11ba2b3a 100644 --- a/src/hooks/useRiveEnum.ts +++ b/src/hooks/useRiveEnum.ts @@ -3,18 +3,32 @@ import { type ViewModelInstance, } from '../specs/ViewModel.nitro'; import type { UseRivePropertyResult } from '../types'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; +import { + type EnumValuesOf, + type TypedViewModelInstance, + type UntypedViewModelInstance, + type VMPropsOfKind, +} from '../core/TypedViewModelInstance'; import { useRiveProperty } from './useRiveProperty'; const getEnumProperty = (vmi: ViewModelInstance, p: string) => vmi.enumProperty(p); -/** - * Hook for interacting with enum ViewModel instance properties. - * - * @param path - The path to the enum property - * @param viewModelInstance - The ViewModelInstance containing the enum property to operate on - * @returns An object with the enum value, a setter function, and an error if the property is not found - */ +export function useRiveEnum< + T extends RiveFileSchema, + N extends Extract, + P extends VMPropsOfKind, +>( + path: P, + viewModelInstance?: TypedViewModelInstance | null +): UseRivePropertyResult>; + +export function useRiveEnum( + path: string, + viewModelInstance?: UntypedViewModelInstance | null +): UseRivePropertyResult; + export function useRiveEnum( path: string, viewModelInstance?: ViewModelInstance | null diff --git a/src/hooks/useRiveFile.ts b/src/hooks/useRiveFile.ts index b9d5d83e..ea7cc651 100644 --- a/src/hooks/useRiveFile.ts +++ b/src/hooks/useRiveFile.ts @@ -12,6 +12,11 @@ import type { ReferencedAssets, ResolvedReferencedAssets, } from '../core/ReferencedAssets'; +import type { + RiveAsset, + RiveFileSchema, + TypedRiveFile, +} from '../core/TypedRiveFile'; export type { ReferencedAssets, ResolvedReferencedAssets }; export type RiveFileInput = number | { uri: string } | string | ArrayBuffer; @@ -92,6 +97,17 @@ export type UseRiveFileResult = | { riveFile: null; isLoading: false; error: Error } | { riveFile: undefined; isLoading: true; error: null }; +export function useRiveFile( + input: RiveAsset, + options?: UseRiveFileOptions +): + | { riveFile: TypedRiveFile; isLoading: false; error: null } + | { riveFile: null; isLoading: false; error: Error } + | { riveFile: undefined; isLoading: true; error: null }; +export function useRiveFile( + input: RiveFileInput | undefined, + options?: UseRiveFileOptions +): UseRiveFileResult; export function useRiveFile( input: RiveFileInput | undefined, options: UseRiveFileOptions = {} diff --git a/src/hooks/useRiveNumber.ts b/src/hooks/useRiveNumber.ts index 773d30e2..4cda0bb0 100644 --- a/src/hooks/useRiveNumber.ts +++ b/src/hooks/useRiveNumber.ts @@ -4,6 +4,12 @@ import { } from '../specs/ViewModel.nitro'; import type { UseRivePropertyResult } from '../types'; import { useRiveProperty } from './useRiveProperty'; +import type { + PathsOfKind, + TypedViewModelInstance, + UntypedViewModelInstance, +} from '../core/TypedViewModelInstance'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; const getNumberProperty = (vmi: ViewModelInstance, p: string) => vmi.numberProperty(p); @@ -15,6 +21,17 @@ const getNumberProperty = (vmi: ViewModelInstance, p: string) => * @param viewModelInstance - The ViewModelInstance containing the number property to operate on * @returns An object with the number value, a setter function, and an error if the property is not found */ +export function useRiveNumber< + T extends RiveFileSchema, + N extends Extract, +>( + path: PathsOfKind, + viewModelInstance?: TypedViewModelInstance | null +): UseRivePropertyResult; +export function useRiveNumber( + path: string, + viewModelInstance?: UntypedViewModelInstance | null +): UseRivePropertyResult; export function useRiveNumber( path: string, viewModelInstance?: ViewModelInstance | null diff --git a/src/hooks/useRiveString.ts b/src/hooks/useRiveString.ts index e182ddff..c5210e2a 100644 --- a/src/hooks/useRiveString.ts +++ b/src/hooks/useRiveString.ts @@ -4,6 +4,12 @@ import { } from '../specs/ViewModel.nitro'; import type { UseRivePropertyResult } from '../types'; import { useRiveProperty } from './useRiveProperty'; +import type { + PathsOfKind, + TypedViewModelInstance, + UntypedViewModelInstance, +} from '../core/TypedViewModelInstance'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; const getStringProperty = (vmi: ViewModelInstance, p: string) => vmi.stringProperty(p); @@ -15,6 +21,17 @@ const getStringProperty = (vmi: ViewModelInstance, p: string) => * @param viewModelInstance - The ViewModelInstance containing the string property to operate on * @returns An object with the number value, a setter function, and an error if the property is not found */ +export function useRiveString< + T extends RiveFileSchema, + N extends Extract, +>( + path: PathsOfKind, + viewModelInstance?: TypedViewModelInstance | null +): UseRivePropertyResult; +export function useRiveString( + path: string, + viewModelInstance?: UntypedViewModelInstance | null +): UseRivePropertyResult; export function useRiveString( path: string, viewModelInstance?: ViewModelInstance | null diff --git a/src/hooks/useRiveTrigger.ts b/src/hooks/useRiveTrigger.ts index 8f8315c9..9ca2c22d 100644 --- a/src/hooks/useRiveTrigger.ts +++ b/src/hooks/useRiveTrigger.ts @@ -8,6 +8,12 @@ import type { UseViewModelInstanceTriggerParameters, } from '../types'; import { useDisposableMemo } from './useDisposableMemo'; +import type { + PathsOfKind, + TypedViewModelInstance, + UntypedViewModelInstance, +} from '../core/TypedViewModelInstance'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; /** * Hook for interacting with trigger ViewModel instance properties. @@ -21,6 +27,19 @@ import { useDisposableMemo } from './useDisposableMemo'; * @param params - Optional parameters including onTrigger callback * @returns A trigger function and any error */ +export function useRiveTrigger< + T extends RiveFileSchema, + N extends Extract, +>( + path: PathsOfKind, + viewModelInstance?: TypedViewModelInstance | null, + params?: UseViewModelInstanceTriggerParameters +): UseRiveTriggerResult; +export function useRiveTrigger( + path: string, + viewModelInstance?: UntypedViewModelInstance | null, + params?: UseViewModelInstanceTriggerParameters +): UseRiveTriggerResult; export function useRiveTrigger( path: string, viewModelInstance?: ViewModelInstance | null, diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 20003553..0b558858 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -3,6 +3,8 @@ import { useMemo, useRef } from 'react'; import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro'; import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { RiveFileSchema } from '../core/TypedRiveFile'; +import type { TypedViewModelInstance } from '../core/TypedViewModelInstance'; import type { RiveViewRef } from '../index'; import { callDispose } from '../core/callDispose'; import { ArtboardByName } from '../specs/ArtboardBy'; @@ -304,6 +306,19 @@ export type UseViewModelInstanceRequiredResult = * }); * ``` */ +// Typed overload: TypedRiveFile + literal viewModelName → TypedViewModelInstance +export function useViewModelInstance< + T extends RiveFileSchema, + N extends Extract, +>( + source: (RiveFile & { readonly __schema?: T }) | null | undefined, + params: UseViewModelInstanceFileParams & { viewModelName: N } +): + | { instance: TypedViewModelInstance; isLoading: false; error: null } + | { instance: null; isLoading: false; error: Error } + | { instance: null; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; + // RiveFile overloads export function useViewModelInstance( source: RiveFile, diff --git a/src/index.tsx b/src/index.tsx index 92fea083..f7feb6b3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,6 +20,21 @@ export { RiveView, type RiveViewProps } from './core/RiveView'; export type { RiveViewMethods }; export type RiveViewRef = HybridView; export type { RiveFile } from './specs/RiveFile.nitro'; +export type { + RiveAsset, + RiveFileSchema, + SchemaOf, + TypedRiveFile, +} from './core/TypedRiveFile'; +export type { + TypedViewModelInstance, + TypedViewModelOf, + UntypedViewModelInstance, + TypedViewModelListProperty, + TypedViewModelEnumProperty, + PathsOfKind, + EnumValuesOf, +} from './core/TypedViewModelInstance'; export type { ViewModel, ViewModelInstance, diff --git a/tsconfig.json b/tsconfig.json index 9a2c358c..eb3b6032 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "skipLibCheck": true, "strict": true, "target": "ESNext", - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "allowArbitraryExtensions": true }, - "exclude": ["expo-example", "expo55-example", "**/*.test-d.ts"] +"exclude": ["expo-example", "expo55-example", "**/*.test-d.ts", "scripts/__tests__"] } diff --git a/yarn.lock b/yarn.lock index c372c698..032d80c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5459,6 +5459,13 @@ __metadata: languageName: node linkType: hard +"@rive-app/canvas@npm:^2.38.0": + version: 2.38.0 + resolution: "@rive-app/canvas@npm:2.38.0" + checksum: a75508c461db26c48d3d3daebd9bd2b1a28fd528062640bd99f767f3b0fc32a518e3bffaadccbab93b3819c7f1225f7d1c65e1534c036798482cf271b5d61161 + languageName: node + linkType: hard + "@rive-app/react-native@workspace:.": version: 0.0.0-use.local resolution: "@rive-app/react-native@workspace:." @@ -5470,6 +5477,7 @@ __metadata: "@react-native/babel-preset": 0.79.2 "@react-native/eslint-config": ^0.78.0 "@release-it/conventional-changelog": ^9.0.2 + "@rive-app/canvas": ^2.38.0 "@testing-library/react-hooks": ^8.0.1 "@testing-library/react-native": ^13.3.3 "@types/jest": ^29.5.5