From 3b96e43803ea1f83b31596427773fa6e0012d9f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 29 Jul 2026 20:02:13 +0200 Subject: [PATCH 1/2] feat: generate referenced asset names in .riv schemas, type referencedAssets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an assets record to generated schemas: unique asset identifier (the documented referencedAssets key form, e.g. 'Inter-594377') → 'image' | 'font' | 'audio'. Only non-embedded assets are listed — referenced and CDN-hosted ones are exactly what an app must supply out-of-band on the new runtime, which does not fetch CDN assets. useRiveFile (and RiveFileFactory.fromSource) then constrain referencedAssets for schema-typed inputs: keys must name an asset in the file, each entry's declared type must match the asset's actual kind, and RiveImage objects are only accepted for image assets. useRiveFile's typed/untyped overload pair is merged into one generic signature so an invalid key errors instead of silently falling through to the untyped number-input overload; unknown schemas and plain inputs degrade to the untyped ReferencedAssets as before. Extraction happens in the existing load() asset-loader callback (the only place the WASM exposes asset metadata). The out-of-band example now uses the typed asset import; every mistake class is pinned by tsd tests and the classifyAsset/emit paths by bun tests, with assets added to the emit fuzzer. --- example/assets/rive/GradientBorder.riv.d.ts | 1 + .../rive/arbtboards-models-instances.riv.d.ts | 1 + example/assets/rive/artboard_db_test.riv.d.ts | 1 + example/assets/rive/blinko.riv.d.ts | 1 + example/assets/rive/bouncing_ball.riv.d.ts | 1 + example/assets/rive/click-count.riv.d.ts | 1 + example/assets/rive/counter.riv.d.ts | 1 + example/assets/rive/databinding.riv.d.ts | 1 + .../assets/rive/databinding_images.riv.d.ts | 1 + .../assets/rive/databinding_lists.riv.d.ts | 1 + example/assets/rive/fallback_fonts.riv.d.ts | 1 + example/assets/rive/font_fallback.riv.d.ts | 1 + example/assets/rive/hello_world_text.riv.d.ts | 1 + example/assets/rive/inputglow.riv.d.ts | 1 + .../ios_android_layouts_demo_v01.riv.d.ts | 1 + example/assets/rive/layout_test.riv.d.ts | 1 + example/assets/rive/layouts_demo.riv.d.ts | 1 + example/assets/rive/many_viewmodels.riv.d.ts | 1 + example/assets/rive/movecircle.riv.d.ts | 1 + .../assets/rive/nodefaultbouncing.riv.d.ts | 1 + example/assets/rive/on_entry_test.riv.d.ts | 3 + example/assets/rive/out_of_band.riv.d.ts | 8 +++ example/assets/rive/quick_start.riv.d.ts | 1 + example/assets/rive/rating.riv.d.ts | 1 + example/assets/rive/rewards.riv.d.ts | 1 + .../assets/rive/style_fallback_fonts.riv.d.ts | 1 + example/assets/rive/tabtest.riv.d.ts | 1 + .../assets/rive/viewmodelproperty.riv.d.ts | 1 + .../assets/rive/vm_value_change_test.riv.d.ts | 1 + example/src/exercisers/OutOfBandAssets.tsx | 38 +++++----- expo-example/assets/rive/rating.riv.d.ts | 1 + .../assets/rive/rewards_source.riv.d.ts | 1 + scripts/__tests__/rive-gen-emit.test.ts | 65 ++++++++++++++++- scripts/__tests__/rive-gen-fuzz.test.ts | Bin 4694 -> 4926 bytes scripts/rive-extract-schema.ts | 15 +++- scripts/rive-gen-types.ts | 67 ++++++++++++++++-- src/__tests__/typed-rive.test-d.ts | 59 +++++++++++++++ src/core/ReferencedAssets.ts | 39 ++++++++++ src/core/RiveFile.ts | 7 +- src/core/TypedRiveFile.ts | 6 ++ src/hooks/useRiveFile.ts | 32 ++++++--- src/index.tsx | 4 ++ 42 files changed, 332 insertions(+), 40 deletions(-) diff --git a/example/assets/rive/GradientBorder.riv.d.ts b/example/assets/rive/GradientBorder.riv.d.ts index 22cc7616..f9af2963 100644 --- a/example/assets/rive/GradientBorder.riv.d.ts +++ b/example/assets/rive/GradientBorder.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ isFocused: 'boolean'; }; }; + assets: {}; }>; 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 index 0f4d9df9..b269f181 100644 --- a/example/assets/rive/arbtboards-models-instances.riv.d.ts +++ b/example/assets/rive/arbtboards-models-instances.riv.d.ts @@ -22,5 +22,6 @@ declare const asset: RiveAsset<{ _id: 'string'; }; }; + assets: {}; }>; 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 index d3e059d3..fe9280e6 100644 --- a/example/assets/rive/artboard_db_test.riv.d.ts +++ b/example/assets/rive/artboard_db_test.riv.d.ts @@ -17,5 +17,6 @@ declare const asset: RiveAsset<{ artboard_2: 'artboard'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/blinko.riv.d.ts b/example/assets/rive/blinko.riv.d.ts index eeb44052..2f187e67 100644 --- a/example/assets/rive/blinko.riv.d.ts +++ b/example/assets/rive/blinko.riv.d.ts @@ -93,5 +93,6 @@ declare const asset: RiveAsset<{ 'property of gameLogicVM': 'viewModel:gameLogicVM'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/bouncing_ball.riv.d.ts b/example/assets/rive/bouncing_ball.riv.d.ts index 80f4b9ed..3338834b 100644 --- a/example/assets/rive/bouncing_ball.riv.d.ts +++ b/example/assets/rive/bouncing_ball.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ ypos: 'number'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/click-count.riv.d.ts b/example/assets/rive/click-count.riv.d.ts index d30dbe90..1a3d37cf 100644 --- a/example/assets/rive/click-count.riv.d.ts +++ b/example/assets/rive/click-count.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ clickCount: 'number'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/counter.riv.d.ts b/example/assets/rive/counter.riv.d.ts index 85084ab5..48e231c9 100644 --- a/example/assets/rive/counter.riv.d.ts +++ b/example/assets/rive/counter.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ cnt: 'number'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/databinding.riv.d.ts b/example/assets/rive/databinding.riv.d.ts index 6f919f93..590807a6 100644 --- a/example/assets/rive/databinding.riv.d.ts +++ b/example/assets/rive/databinding.riv.d.ts @@ -24,5 +24,6 @@ declare const asset: RiveAsset<{ name: 'string'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/databinding_images.riv.d.ts b/example/assets/rive/databinding_images.riv.d.ts index 22274caa..2a0ec197 100644 --- a/example/assets/rive/databinding_images.riv.d.ts +++ b/example/assets/rive/databinding_images.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ bound_image: 'image'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/databinding_lists.riv.d.ts b/example/assets/rive/databinding_lists.riv.d.ts index 1db323e2..125a97c6 100644 --- a/example/assets/rive/databinding_lists.riv.d.ts +++ b/example/assets/rive/databinding_lists.riv.d.ts @@ -16,5 +16,6 @@ declare const asset: RiveAsset<{ team: 'list'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/fallback_fonts.riv.d.ts b/example/assets/rive/fallback_fonts.riv.d.ts index 30de9582..67b1bea3 100644 --- a/example/assets/rive/fallback_fonts.riv.d.ts +++ b/example/assets/rive/fallback_fonts.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ Artboard: 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/font_fallback.riv.d.ts b/example/assets/rive/font_fallback.riv.d.ts index 62e4a100..aa912107 100644 --- a/example/assets/rive/font_fallback.riv.d.ts +++ b/example/assets/rive/font_fallback.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ text: 'string'; }; }; + assets: {}; }>; 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 index 724081b0..532e6118 100644 --- a/example/assets/rive/hello_world_text.riv.d.ts +++ b/example/assets/rive/hello_world_text.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ 'New Artboard': 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/inputglow.riv.d.ts b/example/assets/rive/inputglow.riv.d.ts index 482c9c41..1ad6a2aa 100644 --- a/example/assets/rive/inputglow.riv.d.ts +++ b/example/assets/rive/inputglow.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ isFocused: 'boolean'; }; }; + assets: {}; }>; 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 index 9b0ea4d7..1843cab1 100644 --- a/example/assets/rive/ios_android_layouts_demo_v01.riv.d.ts +++ b/example/assets/rive/ios_android_layouts_demo_v01.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ iOS_Android_Layouts_demo_v01: 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/layout_test.riv.d.ts b/example/assets/rive/layout_test.riv.d.ts index 99c1eb8f..79c4febd 100644 --- a/example/assets/rive/layout_test.riv.d.ts +++ b/example/assets/rive/layout_test.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/layouts_demo.riv.d.ts b/example/assets/rive/layouts_demo.riv.d.ts index 037effe5..58acaaf9 100644 --- a/example/assets/rive/layouts_demo.riv.d.ts +++ b/example/assets/rive/layouts_demo.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ iOS_Android_Layouts_demo_v01: 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/many_viewmodels.riv.d.ts b/example/assets/rive/many_viewmodels.riv.d.ts index f1abe2fb..68f65647 100644 --- a/example/assets/rive/many_viewmodels.riv.d.ts +++ b/example/assets/rive/many_viewmodels.riv.d.ts @@ -15,5 +15,6 @@ declare const asset: RiveAsset<{ textValue: 'string'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/movecircle.riv.d.ts b/example/assets/rive/movecircle.riv.d.ts index 34cb1c49..571437fd 100644 --- a/example/assets/rive/movecircle.riv.d.ts +++ b/example/assets/rive/movecircle.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ posY: 'number'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/nodefaultbouncing.riv.d.ts b/example/assets/rive/nodefaultbouncing.riv.d.ts index b9abfda0..5eb216bc 100644 --- a/example/assets/rive/nodefaultbouncing.riv.d.ts +++ b/example/assets/rive/nodefaultbouncing.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ }; }; + assets: {}; }>; 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 index b50e3df2..1227bbc3 100644 --- a/example/assets/rive/on_entry_test.riv.d.ts +++ b/example/assets/rive/on_entry_test.riv.d.ts @@ -14,5 +14,8 @@ declare const asset: RiveAsset<{ inputOnEntry: 'number'; }; }; + assets: { + 'Inter-4228759': 'font'; + }; }>; 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 index aa987dcc..0bfc7eeb 100644 --- a/example/assets/rive/out_of_band.riv.d.ts +++ b/example/assets/rive/out_of_band.riv.d.ts @@ -9,5 +9,13 @@ declare const asset: RiveAsset<{ Artboard: 'State Machine 1'; }; viewModels: {}; + assets: { + 'Inter-594377': 'font'; + 'referenced-image-2929282': 'image'; + 'referenced_audio-2929340': 'audio'; + 'hosted_audio-2989208': 'audio'; + 'Damion-593587': 'font'; + 'cdn-image-2989123': 'image'; + }; }>; export default asset; diff --git a/example/assets/rive/quick_start.riv.d.ts b/example/assets/rive/quick_start.riv.d.ts index 057517fe..bfcc67f1 100644 --- a/example/assets/rive/quick_start.riv.d.ts +++ b/example/assets/rive/quick_start.riv.d.ts @@ -17,5 +17,6 @@ declare const asset: RiveAsset<{ health: 'number'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/rating.riv.d.ts b/example/assets/rive/rating.riv.d.ts index c234d3af..1c14f47f 100644 --- a/example/assets/rive/rating.riv.d.ts +++ b/example/assets/rive/rating.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ 'New Artboard': 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/rewards.riv.d.ts b/example/assets/rive/rewards.riv.d.ts index 68adbe4f..b838e3ce 100644 --- a/example/assets/rive/rewards.riv.d.ts +++ b/example/assets/rive/rewards.riv.d.ts @@ -47,5 +47,6 @@ declare const asset: RiveAsset<{ Item_Selection: 'enum:Coin|Gem'; }; }; + assets: {}; }>; 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 index bc1a66d1..3ad8a247 100644 --- a/example/assets/rive/style_fallback_fonts.riv.d.ts +++ b/example/assets/rive/style_fallback_fonts.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ Artboard: 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/tabtest.riv.d.ts b/example/assets/rive/tabtest.riv.d.ts index cc6448b3..13847740 100644 --- a/example/assets/rive/tabtest.riv.d.ts +++ b/example/assets/rive/tabtest.riv.d.ts @@ -13,5 +13,6 @@ declare const asset: RiveAsset<{ enumProperty: 'enum:All|Parent|Child'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/viewmodelproperty.riv.d.ts b/example/assets/rive/viewmodelproperty.riv.d.ts index 23cd8185..7aac63b8 100644 --- a/example/assets/rive/viewmodelproperty.riv.d.ts +++ b/example/assets/rive/viewmodelproperty.riv.d.ts @@ -17,5 +17,6 @@ declare const asset: RiveAsset<{ name: 'string'; }; }; + assets: {}; }>; export default asset; diff --git a/example/assets/rive/vm_value_change_test.riv.d.ts b/example/assets/rive/vm_value_change_test.riv.d.ts index b5298568..765be932 100644 --- a/example/assets/rive/vm_value_change_test.riv.d.ts +++ b/example/assets/rive/vm_value_change_test.riv.d.ts @@ -14,5 +14,6 @@ declare const asset: RiveAsset<{ textValue: 'string'; }; }; + assets: {}; }>; export default asset; diff --git a/example/src/exercisers/OutOfBandAssets.tsx b/example/src/exercisers/OutOfBandAssets.tsx index 0ad9d93d..8b72eaea 100644 --- a/example/src/exercisers/OutOfBandAssets.tsx +++ b/example/src/exercisers/OutOfBandAssets.tsx @@ -13,30 +13,30 @@ import { } from '@rive-app/react-native'; import { Picker } from '@react-native-picker/picker'; import { type Metadata } from '../shared/metadata'; +// Typed via the generated out_of_band.riv.d.ts — the referencedAssets keys +// and each entry's `type` below are checked against the file's actual assets. +import outOfBandRiv from '../../assets/rive/out_of_band.riv'; export default function OutOfBandAssetsExample() { const [uri, setUri] = React.useState('https://picsum.photos/id/372/500/500'); - const { riveFile, isLoading, error } = useRiveFile( - require('../../assets/rive/out_of_band.riv'), - { - referencedAssets: { - 'Inter-594377': { - source: require('../../assets/fonts/Inter-594377.ttf'), - type: 'font', - }, - 'referenced-image-2929282': { - source: { - uri: uri, - }, - type: 'image', - }, - 'referenced_audio-2929340': { - source: require('../../assets/audio/referenced_audio-2929340.wav'), - type: 'audio', + const { riveFile, isLoading, error } = useRiveFile(outOfBandRiv, { + referencedAssets: { + 'Inter-594377': { + source: require('../../assets/fonts/Inter-594377.ttf'), + type: 'font', + }, + 'referenced-image-2929282': { + source: { + uri: uri, }, + type: 'image', }, - } - ); + 'referenced_audio-2929340': { + source: require('../../assets/audio/referenced_audio-2929340.wav'), + type: 'audio', + }, + }, + }); if (isLoading) { return ; diff --git a/expo-example/assets/rive/rating.riv.d.ts b/expo-example/assets/rive/rating.riv.d.ts index c234d3af..1c14f47f 100644 --- a/expo-example/assets/rive/rating.riv.d.ts +++ b/expo-example/assets/rive/rating.riv.d.ts @@ -9,5 +9,6 @@ declare const asset: RiveAsset<{ 'New Artboard': 'State Machine 1'; }; viewModels: {}; + assets: {}; }>; export default asset; diff --git a/expo-example/assets/rive/rewards_source.riv.d.ts b/expo-example/assets/rive/rewards_source.riv.d.ts index 0dccac86..bb81c624 100644 --- a/expo-example/assets/rive/rewards_source.riv.d.ts +++ b/expo-example/assets/rive/rewards_source.riv.d.ts @@ -47,5 +47,6 @@ declare const asset: RiveAsset<{ Item_Selection: 'enum:Coin|Gem'; }; }; + assets: {}; }>; export default asset; diff --git a/scripts/__tests__/rive-gen-emit.test.ts b/scripts/__tests__/rive-gen-emit.test.ts index dc384466..ba4fb25c 100644 --- a/scripts/__tests__/rive-gen-emit.test.ts +++ b/scripts/__tests__/rive-gen-emit.test.ts @@ -6,6 +6,8 @@ import { smRecord, vmRecord, schemaBody, + assetsRecord, + classifyAsset, enumTypeString, viewModelRefTypeString, type Schema, @@ -47,6 +49,7 @@ describe('emit escaping', () => { viewModels: { "It's VM": { "quote'": "enum:a'b" }, }, + assets: { "Font's-123": 'font' }, }; const body = schemaBody(schema); expect(parseErrors(`declare const asset: {\n${body}\n};`)).toEqual([]); @@ -78,10 +81,21 @@ describe('schemaBody', () => { defaultArtboard: 'Main', stateMachines: { Main: ['SM'] }, viewModels: {}, + assets: {}, }; - test('always emits viewModels, empty object when none', () => { + test('always emits viewModels and assets, empty objects when none', () => { expect(schemaBody(base)).toContain('viewModels: {};'); + expect(schemaBody(base)).toContain('assets: {};'); + }); + + test('emits assets record when present', () => { + const body = schemaBody({ + ...base, + assets: { 'Inter-594377': 'font', 'img-1': 'image' }, + }); + expect(body).toContain("'Inter-594377': 'font';"); + expect(body).toContain("'img-1': 'image';"); }); test('emits viewModels record when present', () => { @@ -137,3 +151,52 @@ describe('viewModelRefTypeString', () => { expect(viewModelRefTypeString(inst, 'Coin')).toBe('viewModel'); }); }); + +describe('classifyAsset', () => { + const font = { + name: 'Inter', + uniqueFilename: 'Inter-594377.ttf', + fileExtension: 'ttf', + isFont: true, + }; + + test('referenced asset → unique id (extension stripped) + kind', () => { + expect(classifyAsset(font, 0)).toEqual({ + id: 'Inter-594377', + kind: 'font', + }); + expect( + classifyAsset( + { uniqueFilename: 'pic-1.png', fileExtension: 'png', isImage: true }, + 0 + ) + ).toEqual({ id: 'pic-1', kind: 'image' }); + expect( + classifyAsset( + { uniqueFilename: 'a-2.wav', fileExtension: 'wav', isAudio: true }, + 0 + ) + ).toEqual({ id: 'a-2', kind: 'audio' }); + }); + + test('embedded assets are excluded', () => { + expect(classifyAsset(font, 8680)).toBeNull(); + }); + + test('unknown asset kinds are excluded', () => { + expect(classifyAsset({ uniqueFilename: 'x-1.bin' }, 0)).toBeNull(); + }); + + test('falls back to name when uniqueFilename is missing', () => { + expect(classifyAsset({ name: 'Inter', isFont: true }, 0)).toEqual({ + id: 'Inter', + kind: 'font', + }); + }); +}); + +describe('assetsRecord', () => { + test('escapes hostile identifiers', () => { + expect(assetsRecord({ "It's-1": 'font' })).toBe(" 'It\\'s-1': 'font';"); + }); +}); diff --git a/scripts/__tests__/rive-gen-fuzz.test.ts b/scripts/__tests__/rive-gen-fuzz.test.ts index 61fd238d067571987f409f9b3af18afd58d8294b..6f5bf4bcad5ca0cce0735f367697474265389be2 100644 GIT binary patch delta 155 zcmcbnvQKTp3J&h%{Ji24g~a0G)RM^pY!Z`W*|{dOu-j&3XXd3S*eVny=A~$8T5~A? z0a#ISG)O2v*Do;%WJL bB_Lt-#L|?^eD%o-I5OFxrf+894B`X;jZrP4 delta 16 Ycmdm|c1>l&3XaJwoQa!1a{6)t06$9x!vFvP diff --git a/scripts/rive-extract-schema.ts b/scripts/rive-extract-schema.ts index c91b04ab..9c0b3066 100644 --- a/scripts/rive-extract-schema.ts +++ b/scripts/rive-extract-schema.ts @@ -13,7 +13,11 @@ import { readFileSync } from 'fs'; // loader cannot statically see its named exports (bun's interop can). import riveCanvas from '@rive-app/canvas'; const { RuntimeLoader } = riveCanvas; -import { enumTypeString, viewModelRefTypeString } from './rive-gen-types.ts'; +import { + classifyAsset, + enumTypeString, + viewModelRefTypeString, +} from './rive-gen-types.ts'; // noUncheckedIndexedAccess: process.argv destructuring yields string | undefined const input: string | undefined = process.argv[2]; @@ -67,8 +71,13 @@ async function main() { // names/schemas — decoding (images especially) goes through render paths // that stall load() forever without WebGL; a pending load() then drains the // event loop and the process exits 0 without output. + const assets: Record = {}; const assetLoader = new (runtime as any).CustomFileAssetLoader({ - loadContents: () => true, + loadContents: (asset: any, embeddedBytes: Uint8Array | undefined) => { + const classified = classifyAsset(asset ?? {}, embeddedBytes?.length ?? 0); + if (classified) assets[classified.id] = classified.kind; + return true; + }, }); let timer: ReturnType | undefined; @@ -130,7 +139,7 @@ async function main() { const defaultArtboard = artboards[0] ?? ''; process.stdout.write( JSON.stringify( - { artboards, defaultArtboard, stateMachines, viewModels }, + { artboards, defaultArtboard, stateMachines, viewModels, assets }, null, 2 ) + '\n' diff --git a/scripts/rive-gen-types.ts b/scripts/rive-gen-types.ts index 811ae22e..557315d3 100644 --- a/scripts/rive-gen-types.ts +++ b/scripts/rive-gen-types.ts @@ -51,6 +51,45 @@ export interface Schema { defaultArtboard: string; stateMachines: Record; viewModels: Record>; + /** Non-embedded (referenced or CDN) assets: unique identifier → kind. */ + assets: Record; +} + +/** + * Classify a file asset reported by the WASM asset loader. Only non-embedded + * assets (referenced or CDN-hosted) are schema-relevant: they are the ones an + * app must supply via `referencedAssets` — the new runtime does not fetch CDN + * assets itself. The key is the unique identifier (uniqueFilename minus + * extension, e.g. 'Inter-594377'), the form the runtime documents as the + * recommended `referencedAssets` key. + */ +export function classifyAsset( + asset: { + name?: string; + uniqueFilename?: string; + fileExtension?: string; + isImage?: boolean; + isFont?: boolean; + isAudio?: boolean; + }, + embeddedByteCount: number +): { id: string; kind: string } | null { + if (embeddedByteCount > 0) return null; + const kind = asset.isImage + ? 'image' + : asset.isFont + ? 'font' + : asset.isAudio + ? 'audio' + : null; + if (!kind) return null; + const unique = asset.uniqueFilename ?? ''; + const ext = asset.fileExtension ?? ''; + const id = + ext && unique.endsWith(`.${ext}`) + ? unique.slice(0, -(ext.length + 1)) + : unique || (asset.name ?? ''); + return id ? { id, kind } : null; } let runtimeReady: Promise | null = null; @@ -81,8 +120,15 @@ async function extractSchema(input: string): Promise { // names/schemas — decoding (images especially) goes through render paths // that stall load() forever without WebGL, silently truncating the batch: // a pending load() drains bun's event loop and the process exits 0. + // The load() callbacks are also the only place asset metadata is visible, + // so record it here. + const assets: Record = {}; const assetLoader = new (runtime as any).CustomFileAssetLoader({ - loadContents: () => true, + loadContents: (asset: any, embeddedBytes: Uint8Array | undefined) => { + const classified = classifyAsset(asset ?? {}, embeddedBytes?.length ?? 0); + if (classified) assets[classified.id] = classified.kind; + return true; + }, }); let timer: ReturnType | undefined; @@ -145,6 +191,7 @@ async function extractSchema(input: string): Promise { defaultArtboard: artboards[0] ?? '', stateMachines, viewModels, + assets, }; } @@ -191,19 +238,31 @@ export function vmRecord( .join('\n'); } +export function assetsRecord(assets: Record): string { + const keys = Object.keys(assets); + const force = keys.some(needsQuote); + return Object.entries(assets) + .map(([id, kind]) => ` ${quoteKey(id, force)}: ${strLit(kind)};`) + .join('\n'); +} + export function schemaBody(schema: Schema): string { - // Always emit viewModels — omitting it would fail the RiveFileSchema - // constraint and silently degrade the whole asset to untyped. + // Always emit viewModels/assets — omitting either would fail the + // RiveFileSchema constraint and silently degrade the whole asset to untyped. const vmSection = Object.keys(schema.viewModels).length > 0 ? `\n viewModels: {\n${vmRecord(schema.viewModels)}\n };` : '\n viewModels: {};'; + const assetSection = + Object.keys(schema.assets).length > 0 + ? `\n assets: {\n${assetsRecord(schema.assets)}\n };` + : '\n assets: {};'; return `\ artboards: ${schema.artboards.map(strLit).join(' | ')}; defaultArtboard: ${strLit(schema.defaultArtboard)}; stateMachines: { ${smRecord(schema.stateMachines)} - };${vmSection}`; + };${vmSection}${assetSection}`; } function dtsContent(input: string, schema: Schema): string { diff --git a/src/__tests__/typed-rive.test-d.ts b/src/__tests__/typed-rive.test-d.ts index 47510bef..8f38d4a3 100644 --- a/src/__tests__/typed-rive.test-d.ts +++ b/src/__tests__/typed-rive.test-d.ts @@ -24,7 +24,10 @@ import { useRiveNumber } from '../../src/hooks/useRiveNumber'; import { useRiveEnum } from '../../src/hooks/useRiveEnum'; import { useViewModelInstance } from '../../src/hooks/useViewModelInstance'; import { RiveView, type RiveViewProps } from '../../src/core/RiveView'; +import { useRiveFile } from '../../src/hooks/useRiveFile'; +import type { RiveImage } from '../../src/specs/RiveImage.nitro'; import gradientBorderRiv from '../../example/assets/rive/GradientBorder.riv'; +import outOfBandRiv from '../../example/assets/rive/out_of_band.riv'; import blinkoRiv from '../../example/assets/rive/blinko.riv'; import rewardsRiv from '../../example/assets/rive/rewards.riv'; import fallbackFontsRiv from '../../example/assets/rive/fallback_fonts.riv'; @@ -451,3 +454,59 @@ expectAssignable( rewardsVM.numberProperty('Item_Value_Icon/Item_Value') ); expectError(rewardsVM.numberProperty('Item_Value_Icon/DoesNotExist')); + + +// ============================================================ +// Typed referencedAssets (out-of-band assets from the schema) +// ============================================================ + +declare const riveImage: RiveImage; + +// Valid keys with correct types compile; RiveImage allowed for image assets +useRiveFile(outOfBandRiv, { + referencedAssets: { + 'Inter-594377': { source: 1, type: 'font' }, + 'referenced-image-2929282': { source: { uri: 'https://x/i.png' } }, + 'hosted_audio-2989208': { source: 2, type: 'audio' }, + }, +}); +useRiveFile(outOfBandRiv, { + referencedAssets: { 'cdn-image-2989123': riveImage }, +}); + +// Unknown asset key is a hard error (must not fall through to the untyped +// number-input signature) +expectError( + useRiveFile(outOfBandRiv, { + referencedAssets: { 'Inter-59437': { source: 1, type: 'font' } }, + }) +); + +// Declared type must match the asset's actual kind +expectError( + useRiveFile(outOfBandRiv, { + referencedAssets: { 'Inter-594377': { source: 1, type: 'image' } }, + }) +); + +// RiveImage objects are only accepted for image assets +expectError( + useRiveFile(outOfBandRiv, { + referencedAssets: { 'Inter-594377': riveImage }, + }) +); + +// A schema with no referenced assets accepts no keys at all +expectError( + useRiveFile(rewardsRiv, { + referencedAssets: { anything: { source: 1, type: 'font' } }, + }) +); + +// Untyped inputs keep accepting arbitrary keys (backward compat) +useRiveFile('https://example.com/a.riv', { + referencedAssets: { anything: { source: 1, type: 'font' } }, +}); +useRiveFile({ uri: 'file:///a.riv' }, { + referencedAssets: { whatever: { source: 1 } }, +}); diff --git a/src/core/ReferencedAssets.ts b/src/core/ReferencedAssets.ts index 48d11ea5..4c5d0831 100644 --- a/src/core/ReferencedAssets.ts +++ b/src/core/ReferencedAssets.ts @@ -3,6 +3,7 @@ import type { RiveAssetType, } from '../specs/RiveFile.nitro'; import type { RiveImage } from '../specs/RiveImage.nitro'; +import type { RiveFileSchema } from './TypedRiveFile'; export type ReferencedAssetSource = { source: number | { uri: string }; @@ -24,3 +25,41 @@ export interface ReferencedAssets { export type ResolvedReferencedAssets = { [assetName: string]: ResolvedReferencedAsset; }; + +/** + * `referencedAssets` constrained to a generated schema: keys must be asset + * unique identifiers from the .riv file, and each entry's declared `type` + * must match the asset's actual kind. `RiveImage` objects are only accepted + * for image assets. Degrades to the untyped {@link ReferencedAssets} when the + * schema is not statically known. + */ +export type TypedReferencedAssets = + string extends Extract + ? ReferencedAssets + : [Extract] extends [never] + ? // No referenced assets in the file — an empty mapped type would be + // `{}`, which accepts any object; `never` values reject every entry. + Record + : { + [K in Extract< + keyof S['assets'], + string + >]?: S['assets'][K] extends 'image' + ? (ReferencedAssetSource & { type?: 'image' }) | RiveImage + : ReferencedAssetSource & { + type?: S['assets'][K] & RiveAssetType; + }; + }; + +/** + * Resolved (post-`Image.resolveAssetSource`) form of + * {@link TypedReferencedAssets}, used by the RiveFileFactory methods. + */ +export type TypedResolvedReferencedAssets = + string extends Extract + ? ResolvedReferencedAssets + : [Extract] extends [never] + ? Record + : { + [K in Extract]?: ResolvedReferencedAsset; + }; diff --git a/src/core/RiveFile.ts b/src/core/RiveFile.ts index 75e114ae..c4596747 100644 --- a/src/core/RiveFile.ts +++ b/src/core/RiveFile.ts @@ -3,7 +3,10 @@ import type { RiveFileFactory as RiveFileFactoryInternal } from '../specs/RiveFi import type { RiveAsset, RiveFileSchema, TypedRiveFile } from './TypedRiveFile'; import { Image } from 'react-native'; -import type { ResolvedReferencedAssets } from './ReferencedAssets'; +import type { + ResolvedReferencedAssets, + TypedResolvedReferencedAssets, +} from './ReferencedAssets'; const RiveFileInternal = NitroModules.createHybridObject('RiveFileFactory'); @@ -156,7 +159,7 @@ export namespace RiveFileFactory { */ export async function fromSource( source: RiveAsset, - referencedAssets: ResolvedReferencedAssets | undefined, + referencedAssets: TypedResolvedReferencedAssets | undefined, loadCdn?: boolean ): Promise>; export async function fromSource( diff --git a/src/core/TypedRiveFile.ts b/src/core/TypedRiveFile.ts index 82ae93f1..0be35f0f 100644 --- a/src/core/TypedRiveFile.ts +++ b/src/core/TypedRiveFile.ts @@ -16,6 +16,12 @@ export interface RiveFileSchema { defaultArtboard: string; stateMachines: Record; viewModels: Record>; + /** + * Non-embedded (referenced or CDN) assets that the app must supply via + * `referencedAssets`: unique asset identifier (e.g. 'Inter-594377') → + * 'image' | 'font' | 'audio'. + */ + assets: Record; } /** diff --git a/src/hooks/useRiveFile.ts b/src/hooks/useRiveFile.ts index 77449754..78b2690e 100644 --- a/src/hooks/useRiveFile.ts +++ b/src/hooks/useRiveFile.ts @@ -11,6 +11,7 @@ import type { ReferencedAsset, ReferencedAssets, ResolvedReferencedAssets, + TypedReferencedAssets, } from '../core/ReferencedAssets'; import type { RiveAsset, @@ -97,17 +98,26 @@ 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; +/** + * Result of {@link useRiveFile}: typed when the input is a generated + * RiveAsset, the plain untyped result otherwise. + */ +type UseRiveFileResultFor = + string extends T['artboards'] + ? UseRiveFileResult + : + | { riveFile: TypedRiveFile; isLoading: false; error: null } + | { riveFile: null; isLoading: false; error: Error } + | { riveFile: undefined; isLoading: true; error: null }; + +// Single generic signature (not a typed + untyped overload pair): an invalid +// referencedAssets key or type on a schema-typed asset must be a hard error +// here — with separate overloads it would silently fall through to the +// untyped one, because a RiveAsset is assignable to the plain number input. +export function useRiveFile( + input: RiveAsset | Exclude | undefined, + options?: { referencedAssets?: TypedReferencedAssets } +): UseRiveFileResultFor; export function useRiveFile( input: RiveFileInput | undefined, options: UseRiveFileOptions = {} diff --git a/src/index.tsx b/src/index.tsx index 6dc2675c..403a4c59 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -88,6 +88,10 @@ export { type UseViewModelInstanceRequiredResult, } from './hooks/useViewModelInstance'; export { useRiveFile, type UseRiveFileResult } from './hooks/useRiveFile'; +export type { + TypedReferencedAssets, + TypedResolvedReferencedAssets, +} from './core/ReferencedAssets'; export { type RiveFileInput } from './hooks/useRiveFile'; export { type SetValueAction } from './types'; export { RiveRuntime } from './core/RiveRuntime'; From e7b8a402b0294ae88bbc2832082505b1e86d6e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 30 Jul 2026 06:45:05 +0200 Subject: [PATCH 2/2] fix(types): explain the no-referenced-assets rejection, pin all untyped input forms A referencedAssets entry on a file with no referenced assets errored with an unexplained 'not assignable to never'; the rejection value is now a literal message type so the compiler error itself says why. Also pin that every legacy input form (url string, uri object, require()/any, plain number) keeps accepting arbitrary asset keys. --- src/__tests__/typed-rive.test-d.ts | 11 ++++++++++- src/core/ReferencedAssets.ts | 11 +++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/__tests__/typed-rive.test-d.ts b/src/__tests__/typed-rive.test-d.ts index 8f38d4a3..d15c1d1f 100644 --- a/src/__tests__/typed-rive.test-d.ts +++ b/src/__tests__/typed-rive.test-d.ts @@ -503,10 +503,19 @@ expectError( }) ); -// Untyped inputs keep accepting arbitrary keys (backward compat) +// Untyped inputs keep accepting arbitrary keys (backward compat) — every +// legacy input form: url string, uri object, require() (any), plain number. useRiveFile('https://example.com/a.riv', { referencedAssets: { anything: { source: 1, type: 'font' } }, }); useRiveFile({ uri: 'file:///a.riv' }, { referencedAssets: { whatever: { source: 1 } }, }); +declare const requireResult: any; +useRiveFile(requireResult, { + referencedAssets: { anything: { source: 1, type: 'font' } }, +}); +declare const plainAssetId: number; +useRiveFile(plainAssetId, { + referencedAssets: { anything: { source: 1, type: 'audio' } }, +}); diff --git a/src/core/ReferencedAssets.ts b/src/core/ReferencedAssets.ts index 4c5d0831..b9bb763b 100644 --- a/src/core/ReferencedAssets.ts +++ b/src/core/ReferencedAssets.ts @@ -37,9 +37,12 @@ export type TypedReferencedAssets = string extends Extract ? ReferencedAssets : [Extract] extends [never] - ? // No referenced assets in the file — an empty mapped type would be - // `{}`, which accepts any object; `never` values reject every entry. - Record + ? // No referenced assets in the file. An empty mapped type would be + // `{}`, which accepts any object — and a `never` value would reject + // entries with an unexplained "not assignable to never". The literal + // string type below rejects real entries too, but surfaces the reason + // in the compiler error itself. + Record : { [K in Extract< keyof S['assets'], @@ -59,7 +62,7 @@ export type TypedResolvedReferencedAssets = string extends Extract ? ResolvedReferencedAssets : [Extract] extends [never] - ? Record + ? Record : { [K in Extract]?: ResolvedReferencedAsset; };