From 643eea49de20fac358a1ee58c4995d526dd843c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 27 Jul 2026 09:24:38 +0200 Subject: [PATCH 1/3] fix(types): degrade unknown schemas to string instead of never, harden typed overloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from review of the type-safe schema system: - Files without a generated schema (base RiveFileSchema — everything from RiveFileFactory.* and useRiveFile(require(...))) no longer produce TypedViewModelInstance property paths that collapse to never; all path/name constraints degrade to string so existing untyped code keeps compiling. - viewModelName on a schema-typed file is now a hard error when it names a nonexistent ViewModel, instead of silently falling through to the untyped overload and disabling all downstream path checking. - The schema-aware file overloads carry the same async/required/deprecated composition as the untyped ones: required: true keeps its non-null narrowing, and the sync path stays marked @deprecated for typed calls. - Instance accessors and useRiveEnum accept nested paths (up to two hops, matching the sibling hooks); enum value unions resolve through the path. - List elements are plain ViewModelInstance — the schema cannot know which ViewModel a list holds, and the previous all-VMs union made every accessor parameter intersect to never. - TypedViewModelEnumProperty also narrows setValueAsync. All pinned by new tsd tests, including the legacy untyped flows. --- src/__tests__/typed-rive.test-d.ts | 176 ++++++++++++++++++++++++++--- src/core/TypedViewModelInstance.ts | 161 ++++++++++++++++---------- src/hooks/useRiveEnum.ts | 7 +- src/hooks/useViewModelInstance.ts | 131 ++++++++++++++++----- src/index.tsx | 2 +- 5 files changed, 373 insertions(+), 104 deletions(-) diff --git a/src/__tests__/typed-rive.test-d.ts b/src/__tests__/typed-rive.test-d.ts index b62232ef..5c414ff8 100644 --- a/src/__tests__/typed-rive.test-d.ts +++ b/src/__tests__/typed-rive.test-d.ts @@ -1,8 +1,13 @@ -import { expectType, expectError, expectAssignable } from 'tsd'; +import { + expectType, + expectError, + expectAssignable, + expectDeprecated, + expectNotDeprecated, +} from 'tsd'; import type { TypedRiveFile, RiveAsset } from '../../src/core/TypedRiveFile'; import type { TypedViewModelInstance, - TypedViewModelListProperty, TypedViewModelEnumProperty, UntypedViewModelInstance, } from '../../src/core/TypedViewModelInstance'; @@ -11,12 +16,18 @@ import type { ViewModelNumberProperty, ViewModelTriggerProperty, ViewModelBooleanProperty, + ViewModelListProperty, } from '../../src/specs/ViewModel.nitro'; +import type { RiveFile } from '../../src/specs/RiveFile.nitro'; import type { UseRivePropertyResult } from '../../src/types'; import { useRiveNumber } from '../../src/hooks/useRiveNumber'; +import { useRiveEnum } from '../../src/hooks/useRiveEnum'; +import { useViewModelInstance } from '../../src/hooks/useViewModelInstance'; import type { RiveViewProps } from '../../src/core/RiveView'; import gradientBorderRiv from '../../example/assets/rive/GradientBorder.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'; // Infer schemas from the generated .riv.d.ts assets type GradientBorderSchema = typeof gradientBorderRiv extends RiveAsset @@ -215,21 +226,26 @@ expectError(storeVM.enumProperty('doesNotExist')); // --- List property --- -// storeVM.items is a 'list' — returns a typed list property -expectAssignable | undefined>( - storeVM.listProperty('items') -); +// storeVM.items is a 'list' — path is constrained, the property itself is the +// plain runtime type: the schema cannot know which ViewModel a list holds at a +// given index, and a union element would make every accessor parameter +// intersect to `never` (unusable). +expectType(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)); +// List elements are untyped instances — usable with any accessor / untyped hooks +declare const listProp: ViewModelListProperty; +expectType>( + listProp.getInstanceAtAsync(0) +); +async function listElementIsUsable() { + const el = await listProp.getInstanceAtAsync(0); + el?.numberProperty('anything'); + useRiveNumber('any/path', el); +} +void listElementIsUsable; // ============================================================ // useRiveNumber @@ -272,3 +288,137 @@ expectType>( // No instance: falls back to untyped overload, still returns number result expectType>(useRiveNumber('multiplierValue')); + +// ============================================================ +// useViewModelInstance — schema-aware file overloads +// ============================================================ + +declare const plainFile: RiveFile; + +// --- Files without a generated schema degrade to untyped (never `never`) --- +// This is the load-bearing backward-compat guarantee: RiveFileFactory.* and +// useRiveFile(require(...)) produce TypedRiveFile, and code +// that passes viewModelName + uses untyped hooks must keep compiling. +{ + const { instance } = useViewModelInstance(plainFile, { + viewModelName: 'AnyNameAtAll', + async: true, + }); + expectType(instance); + useRiveNumber('score', instance); + if (instance) { + instance.numberProperty('score'); + useRiveNumber('nested/path', instance); + } +} + +declare const baseTypedFile: TypedRiveFile; +{ + const { instance } = useViewModelInstance(baseTypedFile, { + viewModelName: 'AnyNameAtAll', + async: true, + }); + expectType(instance); + useRiveNumber('score', instance); +} + +// --- Schema-typed file + valid viewModelName → typed instance --- +{ + const { instance } = useViewModelInstance(blinkoFile, { + viewModelName: 'storeVM', + async: true, + }); + expectType< + TypedViewModelInstance | null | undefined + >(instance); +} + +// --- Invalid viewModelName on a schema-typed file is a hard error --- +// It must NOT silently fall through to an untyped overload. +expectError( + useViewModelInstance(blinkoFile, { + viewModelName: 'NotAViewModel', + async: true, + }) +); + +// --- Typed file without viewModelName → untyped result (default instance) --- +{ + const { instance } = useViewModelInstance(blinkoFile, { async: true }); + expectType(instance); +} + +// --- required: true keeps its narrowing on the typed overload --- +{ + const { instance } = useViewModelInstance(blinkoFile, { + viewModelName: 'storeVM', + async: true, + required: true, + }); + expectType | undefined>( + instance + ); +} + +// --- Deprecation composes with the typed overloads --- +// Without async: true the sync (JS-thread-blocking) path is used — the typed +// call must carry the same @deprecated marker as the untyped one. +expectDeprecated(useViewModelInstance(blinkoFile, { viewModelName: 'storeVM' })); +expectDeprecated(useViewModelInstance(plainFile)); +expectNotDeprecated( + useViewModelInstance(blinkoFile, { viewModelName: 'storeVM', async: true }) +); + +// ============================================================ +// Schemas without ViewModels still type artboards/state machines +// ============================================================ + +type FallbackFontsSchema = + typeof fallbackFontsRiv extends RiveAsset ? T : never; +declare const fontsFile: TypedRiveFile; + +expectAssignable>({ + file: fontsFile, + artboardName: 'Artboard', +}); +expectError>({ + file: fontsFile, + artboardName: 'NotAnArtboard', +}); + +// A file with no ViewModels accepts no viewModelName at all +expectError( + useViewModelInstance(fontsFile, { viewModelName: 'anything', async: true }) +); + +// ============================================================ +// useRiveEnum — nested paths (parity with the sibling hooks) +// ============================================================ + +type RewardsSchema = typeof rewardsRiv extends RiveAsset ? T : never; +declare const rewardsVM: TypedViewModelInstance; + +// Direct enum path on the owning VM +declare const itemVM: TypedViewModelInstance; +expectType>( + useRiveEnum('Item_Selection', itemVM) +); + +// Nested enum path — Item_Selection on Rewards is 'viewModel:Item' +expectType>( + useRiveEnum('Item_Selection/Item_Selection', rewardsVM) +); + +// Two-hop nested enum path: Rewards → Item_Value_Icon → Property_Of_Item → Item_Selection +expectType>( + useRiveEnum('Item_Value_Icon/Property_Of_Item/Item_Selection', rewardsVM) +); + +// Wrong nested enum path errors +expectError(useRiveEnum('Item_Selection/DoesNotExist', rewardsVM)); + +// Instance accessors accept nested paths too +expectAssignable( + rewardsVM.numberProperty('Item_Value_Icon/Item_Value') +); +expectError(rewardsVM.numberProperty('Item_Value_Icon/DoesNotExist')); diff --git a/src/core/TypedViewModelInstance.ts b/src/core/TypedViewModelInstance.ts index 0f354d5f..78547c9f 100644 --- a/src/core/TypedViewModelInstance.ts +++ b/src/core/TypedViewModelInstance.ts @@ -12,25 +12,14 @@ import type { 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. + * True when nothing is statically known about `T`'s ViewModels — i.e. `T` is + * the structural base `RiveFileSchema` (index-signature keys) rather than a + * generated schema with literal names. Every path/name constraint must degrade + * to `string` in that case, never to `never`: files without a generated schema + * still work at runtime, so they must keep compiling. */ -export interface TypedViewModelListProperty - extends Omit { - getInstanceAtAsync( - index: number - ): Promise< - | TypedViewModelInstance> - | undefined - >; - /** @deprecated Use getInstanceAtAsync instead */ - getInstanceAt( - index: number - ): - | TypedViewModelInstance> - | undefined; -} +export type IsBaseSchema = + string extends Extract ? true : false; /** Split a pipe-separated string literal into a union: 'a|b|c' → 'a' | 'b' | 'c' */ type UnionFromPipe = S extends `${infer A}|${infer B}` @@ -38,9 +27,11 @@ type UnionFromPipe = S extends `${infer A}|${infer B}` : 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; +export type EnumValuesOf = string extends S + ? string + : S extends `enum:${infer V}` + ? UnionFromPipe + : never; /** * A typed enum property whose value and setter are constrained to the specific enum values @@ -49,42 +40,53 @@ export type EnumValuesOf = S extends `enum:${infer V}` export interface TypedViewModelEnumProperty extends Omit< ViewModelEnumProperty, - 'value' | 'getValueAsync' | 'set' | 'addListener' + 'value' | 'getValueAsync' | 'set' | 'setValueAsync' | 'addListener' > { /** @deprecated Use getValueAsync (read) or set(value) (write) instead */ value: Values; getValueAsync(): Promise; set(value: Values): void; + setValueAsync(value: Values): Promise; 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). + * Degrades to `string` when the ViewModel shape is not statically known. */ 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; +> = + string extends Extract + ? 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; +export type VMRefName = string extends TypeStr + ? string + : 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; +export type VMRefPropNames> = + string extends Extract + ? string + : { + [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< @@ -94,33 +96,76 @@ type VMRefNameResolved< > = VMRefName & Extract; +/** + * Nesting depth for path recursion. Capped so that cyclic ViewModel reference + * graphs (A → B → A) terminate; 2 extra hops covers paths like 'a/b/leaf'. + */ +type NestDepth = 0 | 1 | 2; +type PrevDepth = { 0: 0; 1: 0; 2: 1 }; + type NestedPathsOfKind< T extends RiveFileSchema, VMName extends keyof T['viewModels'] & string, Kind extends string, -> = { - [P in VMRefPropNames< - T['viewModels'][VMName] - >]: `${P}/${VMPropsOfKind], Kind>}`; -}[VMRefPropNames]; + Depth extends NestDepth, +> = Depth extends 0 + ? never + : { + [P in VMRefPropNames< + T['viewModels'][VMName] + >]: `${P}/${PathsOfKind, Kind, PrevDepth[Depth]>}`; + }[VMRefPropNames]; /** - * All valid property paths of a given kind, including one level of nested ViewModel paths. + * All valid property paths of a given kind, including nested ViewModel paths + * (up to two hops deep — deeper paths need a cast). * Direct paths: `'Price_Value'` - * Nested paths: `'Coin/Item_Value'` + * Nested paths: `'Coin/Item_Value'`, `'Coin/Property_Of_Item/Item_Selection'` + * Degrades to `string` when the schema is not statically known. */ export type PathsOfKind< T extends RiveFileSchema, VMName extends keyof T['viewModels'] & string, Kind extends string, + Depth extends NestDepth = 2, +> = + IsBaseSchema extends true + ? string + : + | VMPropsOfKind + | NestedPathsOfKind; + +/** + * The schema type string found at a (possibly nested) property path, + * e.g. `'number'` or `'enum:Coin|Gem'`. `string` when the schema is not + * statically known. + */ +export type PropTypeAtPath< + T extends RiveFileSchema, + VMName extends keyof T['viewModels'] & string, + P extends string, > = - | VMPropsOfKind - | NestedPathsOfKind; + IsBaseSchema extends true + ? string + : P extends `${infer Head}/${infer Rest}` + ? Head extends VMRefPropNames + ? PropTypeAtPath< + T, + VMRefNameResolved, + Rest + > + : never + : P extends keyof T['viewModels'][VMName] & string + ? T['viewModels'][VMName][P] & string + : never; /** * A ViewModelInstance typed to a specific ViewModel schema entry. - * Property accessor methods are constrained to valid property names. + * Property accessor methods are constrained to valid property paths (direct or + * nested, e.g. `'Coin/Item_Value'`). * `.viewModel(path)` returns a TypedViewModelInstance for the referenced ViewModel. + * List elements are untyped — the schema cannot know which ViewModel a list + * holds at a given index. * * Obtain via `useViewModelInstance(typedFile, { viewModelName: 'MyVM' })`. */ @@ -141,38 +186,38 @@ export interface TypedViewModelInstance< | 'viewModelAsync' > { numberProperty( - path: VMPropsOfKind + path: PathsOfKind ): ViewModelNumberProperty | undefined; stringProperty( - path: VMPropsOfKind + path: PathsOfKind ): ViewModelStringProperty | undefined; booleanProperty( - path: VMPropsOfKind + path: PathsOfKind ): ViewModelBooleanProperty | undefined; colorProperty( - path: VMPropsOfKind + path: PathsOfKind ): ViewModelColorProperty | undefined; triggerProperty( - path: VMPropsOfKind + path: PathsOfKind ): ViewModelTriggerProperty | undefined; - enumProperty

>( + enumProperty

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

>( diff --git a/src/hooks/useRiveEnum.ts b/src/hooks/useRiveEnum.ts index 11ba2b3a..fb87d65c 100644 --- a/src/hooks/useRiveEnum.ts +++ b/src/hooks/useRiveEnum.ts @@ -6,9 +6,10 @@ import type { UseRivePropertyResult } from '../types'; import type { RiveFileSchema } from '../core/TypedRiveFile'; import { type EnumValuesOf, + type PathsOfKind, + type PropTypeAtPath, type TypedViewModelInstance, type UntypedViewModelInstance, - type VMPropsOfKind, } from '../core/TypedViewModelInstance'; import { useRiveProperty } from './useRiveProperty'; @@ -18,11 +19,11 @@ const getEnumProperty = (vmi: ViewModelInstance, p: string) => export function useRiveEnum< T extends RiveFileSchema, N extends Extract, - P extends VMPropsOfKind, + P extends PathsOfKind, >( path: P, viewModelInstance?: TypedViewModelInstance | null -): UseRivePropertyResult>; +): UseRivePropertyResult>>; export function useRiveEnum( path: string, diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index 448555e9..778233c7 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -4,7 +4,10 @@ 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 { + IsBaseSchema, + TypedViewModelInstance, +} from '../core/TypedViewModelInstance'; import type { RiveViewRef } from '../index'; import { callDispose } from '../core/callDispose'; import { ArtboardByName } from '../specs/ArtboardBy'; @@ -85,6 +88,31 @@ export type UseViewModelInstanceFileParams = | UseViewModelInstanceFileByArtboard | UseViewModelInstanceFileByViewModelName; +/** + * Valid `viewModelName` values for a file with schema `S` — any string when + * nothing is statically known about the file. + */ +type VMNameArg = + IsBaseSchema extends true + ? string + : Extract; + +/** + * File params whose `viewModelName` is constrained to the file's schema. + * Intersecting `N` with the valid names (instead of constraining `N` itself) + * makes an invalid name a hard error inside this overload rather than a + * constraint failure that would silently fall through to another overload. + */ +type UseViewModelInstanceFileParamsFor< + S extends RiveFileSchema, + N extends string, +> = + | UseViewModelInstanceFileDefault + | UseViewModelInstanceFileByArtboard + | (Omit & { + viewModelName: N & VMNameArg; + }); + export interface UseViewModelInstanceViewModelParams extends UseViewModelInstanceBaseParams { /** @@ -220,6 +248,45 @@ export type UseViewModelInstanceRequiredResult = | { instance: ViewModelInstance; isLoading: false; error: null } | { instance: undefined; isLoading: true; error: null }; +/** + * File-source result: typed when the schema is statically known and + * `viewModelName` names one of its ViewModels, untyped otherwise. + */ +type UseViewModelInstanceFileResult< + S extends RiveFileSchema, + N extends string, +> = + IsBaseSchema extends true + ? UseViewModelInstanceResult + : N extends Extract + ? + | { + instance: TypedViewModelInstance; + isLoading: false; + error: null; + } + | { instance: null; isLoading: false; error: Error } + | { instance: null; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null } + : UseViewModelInstanceResult; + +/** File-source result with `required: true` — the `null` cases are removed. */ +type UseViewModelInstanceFileRequiredResult< + S extends RiveFileSchema, + N extends string, +> = + IsBaseSchema extends true + ? UseViewModelInstanceRequiredResult + : N extends Extract + ? + | { + instance: TypedViewModelInstance; + isLoading: false; + error: null; + } + | { instance: undefined; isLoading: true; error: null } + : UseViewModelInstanceRequiredResult; + /** * Hook for getting a ViewModelInstance from a RiveFile, ViewModel, or RiveViewRef. * @@ -311,38 +378,44 @@ export type UseViewModelInstanceRequiredResult = * }); * ``` */ -// Typed overload: TypedRiveFile + literal viewModelName → TypedViewModelInstance +// RiveFile overloads — schema-aware. When the file carries a generated schema +// and `viewModelName` names one of its ViewModels, the instance is typed; +// files without a generated schema (base RiveFileSchema) degrade to the +// untyped result. An invalid `viewModelName` on a schema-typed file is a hard +// error — it must not silently fall through to an untyped overload. export function useViewModelInstance< - T extends RiveFileSchema, - N extends Extract, + S extends RiveFileSchema = RiveFileSchema, + N extends string = string, >( - 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, - params: UseViewModelInstanceFileParams & { async: true; required: true } -): UseViewModelInstanceRequiredResult; -export function useViewModelInstance( - source: RiveFile | null | undefined, - params: UseViewModelInstanceFileParams & { async: true } -): UseViewModelInstanceResult; + source: RiveFile & { readonly __schema?: S }, + params: UseViewModelInstanceFileParamsFor & { + async: true; + required: true; + } +): UseViewModelInstanceFileRequiredResult; +export function useViewModelInstance< + S extends RiveFileSchema = RiveFileSchema, + N extends string = string, +>( + source: (RiveFile & { readonly __schema?: S }) | null | undefined, + params: UseViewModelInstanceFileParamsFor & { async: true } +): UseViewModelInstanceFileResult; /** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ -export function useViewModelInstance( - source: RiveFile, - params: UseViewModelInstanceFileParams & { required: true } -): UseViewModelInstanceRequiredResult; +export function useViewModelInstance< + S extends RiveFileSchema = RiveFileSchema, + N extends string = string, +>( + source: RiveFile & { readonly __schema?: S }, + params: UseViewModelInstanceFileParamsFor & { required: true } +): UseViewModelInstanceFileRequiredResult; /** @deprecated Pass `async: true` — without it the instance is created synchronously via deprecated runtime APIs that block the JS thread. `async: true` becomes the default in the next major. If your params object's `async` widened to `boolean`, re-pin it at the call site: `{ ...params, async: true }`. */ -export function useViewModelInstance( - source: RiveFile | null | undefined, - params?: UseViewModelInstanceFileParams -): UseViewModelInstanceResult; +export function useViewModelInstance< + S extends RiveFileSchema = RiveFileSchema, + N extends string = string, +>( + source: (RiveFile & { readonly __schema?: S }) | null | undefined, + params?: UseViewModelInstanceFileParamsFor +): UseViewModelInstanceFileResult; // ViewModel overloads export function useViewModelInstance( diff --git a/src/index.tsx b/src/index.tsx index 3e0e8c29..6dc2675c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -35,9 +35,9 @@ export type { TypedViewModelInstance, TypedViewModelOf, UntypedViewModelInstance, - TypedViewModelListProperty, TypedViewModelEnumProperty, PathsOfKind, + PropTypeAtPath, EnumValuesOf, } from './core/TypedViewModelInstance'; export type { From ade067f6d75d50f6ac110f5cb43369d6576e59a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 27 Jul 2026 09:24:38 +0200 Subject: [PATCH 2/3] fix(scripts): fail schema batch loudly, emit empty viewModels, escape names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rive-gen-types --all silently validated only a prefix of the assets: an image decode without WebGL stalls the WASM load() forever, the pending promise drains bun's event loop, and the process exits 0 mid-batch. The asset loader now claims assets without decoding (introspection needs no pixels), each load() gets a 30s watchdog, and the batch continues past failures and exits non-zero with a summary. click-count.riv.d.ts is new — it sat in the skipped tail, so its schema was never generated. - Schemas with no ViewModels now emit viewModels: {} — omitting the key failed the RiveFileSchema constraint and silently degraded those assets to fully untyped (7 committed schemas affected). - Quotes/backslashes in artboard/VM/property/enum names are escaped in the emitted .d.ts; an enum value containing the '|' separator falls back to untyped 'enum' with a warning instead of corrupting the union. - Generated header uses /* eslint-disable */ (// eslint-disable is a no-op). - CI schema validation uses git status instead of git diff so newly generated but untracked .riv.d.ts files also fail the check. --- .github/workflows/ci.yml | 4 +- example/assets/rive/GradientBorder.riv.d.ts | 2 +- .../rive/arbtboards-models-instances.riv.d.ts | 2 +- example/assets/rive/artboard_db_test.riv.d.ts | 2 +- example/assets/rive/blinko.riv.d.ts | 2 +- example/assets/rive/bouncing_ball.riv.d.ts | 2 +- example/assets/rive/click-count.riv.d.ts | 17 +++ example/assets/rive/counter.riv.d.ts | 2 +- example/assets/rive/databinding.riv.d.ts | 2 +- .../assets/rive/databinding_images.riv.d.ts | 2 +- .../assets/rive/databinding_lists.riv.d.ts | 2 +- example/assets/rive/fallback_fonts.riv.d.ts | 3 +- example/assets/rive/font_fallback.riv.d.ts | 2 +- example/assets/rive/hello_world_text.riv.d.ts | 3 +- example/assets/rive/inputglow.riv.d.ts | 2 +- .../ios_android_layouts_demo_v01.riv.d.ts | 3 +- example/assets/rive/layout_test.riv.d.ts | 2 +- example/assets/rive/layouts_demo.riv.d.ts | 3 +- example/assets/rive/many_viewmodels.riv.d.ts | 2 +- example/assets/rive/movecircle.riv.d.ts | 2 +- .../assets/rive/nodefaultbouncing.riv.d.ts | 2 +- example/assets/rive/on_entry_test.riv.d.ts | 2 +- example/assets/rive/out_of_band.riv.d.ts | 3 +- example/assets/rive/quick_start.riv.d.ts | 2 +- example/assets/rive/rating.riv.d.ts | 3 +- example/assets/rive/rewards.riv.d.ts | 2 +- .../assets/rive/style_fallback_fonts.riv.d.ts | 3 +- example/assets/rive/tabtest.riv.d.ts | 2 +- .../assets/rive/viewmodelproperty.riv.d.ts | 2 +- .../assets/rive/vm_value_change_test.riv.d.ts | 2 +- scripts/rive-extract-schema.ts | 65 ++++------ scripts/rive-gen-types.ts | 114 ++++++++++-------- 32 files changed, 143 insertions(+), 120 deletions(-) create mode 100644 example/assets/rive/click-count.riv.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e00194b2..b0883476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,9 @@ jobs: - 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 + # git status (not diff) also catches newly generated, untracked .riv.d.ts + if [ -n "$(git status --porcelain -- 'example/assets/rive/*.riv.d.ts')" ]; then + git status --porcelain -- 'example/assets/rive/*.riv.d.ts' 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 diff --git a/example/assets/rive/GradientBorder.riv.d.ts b/example/assets/rive/GradientBorder.riv.d.ts index a964f379..22cc7616 100644 --- a/example/assets/rive/GradientBorder.riv.d.ts +++ b/example/assets/rive/GradientBorder.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: GradientBorder.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/arbtboards-models-instances.riv.d.ts b/example/assets/rive/arbtboards-models-instances.riv.d.ts index 9da47486..0f4d9df9 100644 --- a/example/assets/rive/arbtboards-models-instances.riv.d.ts +++ b/example/assets/rive/arbtboards-models-instances.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: arbtboards-models-instances.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/artboard_db_test.riv.d.ts b/example/assets/rive/artboard_db_test.riv.d.ts index 6b8b0eb6..d3e059d3 100644 --- a/example/assets/rive/artboard_db_test.riv.d.ts +++ b/example/assets/rive/artboard_db_test.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: artboard_db_test.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/blinko.riv.d.ts b/example/assets/rive/blinko.riv.d.ts index 8266f094..eeb44052 100644 --- a/example/assets/rive/blinko.riv.d.ts +++ b/example/assets/rive/blinko.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: blinko.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/bouncing_ball.riv.d.ts b/example/assets/rive/bouncing_ball.riv.d.ts index 10bc5f04..80f4b9ed 100644 --- a/example/assets/rive/bouncing_ball.riv.d.ts +++ b/example/assets/rive/bouncing_ball.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: bouncing_ball.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/click-count.riv.d.ts b/example/assets/rive/click-count.riv.d.ts new file mode 100644 index 00000000..d30dbe90 --- /dev/null +++ b/example/assets/rive/click-count.riv.d.ts @@ -0,0 +1,17 @@ +// Generated by rive-gen-types — do not edit manually. @generated +/* eslint-disable */ +// Source: click-count.riv +import type { RiveAsset } from '@rive-app/react-native'; +declare const asset: RiveAsset<{ + artboards: 'Artboard'; + defaultArtboard: 'Artboard'; + stateMachines: { + Artboard: 'State Machine 1'; + }; + viewModels: { + ViewModel: { + clickCount: 'number'; + }; + }; +}>; +export default asset; diff --git a/example/assets/rive/counter.riv.d.ts b/example/assets/rive/counter.riv.d.ts index 244bbe14..85084ab5 100644 --- a/example/assets/rive/counter.riv.d.ts +++ b/example/assets/rive/counter.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: counter.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/databinding.riv.d.ts b/example/assets/rive/databinding.riv.d.ts index f0b630e5..6f919f93 100644 --- a/example/assets/rive/databinding.riv.d.ts +++ b/example/assets/rive/databinding.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: databinding.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/databinding_images.riv.d.ts b/example/assets/rive/databinding_images.riv.d.ts index b9d93374..22274caa 100644 --- a/example/assets/rive/databinding_images.riv.d.ts +++ b/example/assets/rive/databinding_images.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: databinding_images.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/databinding_lists.riv.d.ts b/example/assets/rive/databinding_lists.riv.d.ts index 00334476..1db323e2 100644 --- a/example/assets/rive/databinding_lists.riv.d.ts +++ b/example/assets/rive/databinding_lists.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: databinding_lists.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/fallback_fonts.riv.d.ts b/example/assets/rive/fallback_fonts.riv.d.ts index 60e8aa1d..30de9582 100644 --- a/example/assets/rive/fallback_fonts.riv.d.ts +++ b/example/assets/rive/fallback_fonts.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: fallback_fonts.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { Artboard: 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/font_fallback.riv.d.ts b/example/assets/rive/font_fallback.riv.d.ts index 02bdda87..62e4a100 100644 --- a/example/assets/rive/font_fallback.riv.d.ts +++ b/example/assets/rive/font_fallback.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: font_fallback.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/hello_world_text.riv.d.ts b/example/assets/rive/hello_world_text.riv.d.ts index 4e879f1e..724081b0 100644 --- a/example/assets/rive/hello_world_text.riv.d.ts +++ b/example/assets/rive/hello_world_text.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: hello_world_text.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { 'New Artboard': 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/inputglow.riv.d.ts b/example/assets/rive/inputglow.riv.d.ts index c1a84c21..482c9c41 100644 --- a/example/assets/rive/inputglow.riv.d.ts +++ b/example/assets/rive/inputglow.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: inputglow.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ 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 549ba94c..9b0ea4d7 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 @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: ios_android_layouts_demo_v01.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { iOS_Android_Layouts_demo_v01: 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/layout_test.riv.d.ts b/example/assets/rive/layout_test.riv.d.ts index 25234ee9..99c1eb8f 100644 --- a/example/assets/rive/layout_test.riv.d.ts +++ b/example/assets/rive/layout_test.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: layout_test.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/layouts_demo.riv.d.ts b/example/assets/rive/layouts_demo.riv.d.ts index ac330ad0..037effe5 100644 --- a/example/assets/rive/layouts_demo.riv.d.ts +++ b/example/assets/rive/layouts_demo.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: layouts_demo.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { iOS_Android_Layouts_demo_v01: 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/many_viewmodels.riv.d.ts b/example/assets/rive/many_viewmodels.riv.d.ts index 4e717058..f1abe2fb 100644 --- a/example/assets/rive/many_viewmodels.riv.d.ts +++ b/example/assets/rive/many_viewmodels.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: many_viewmodels.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/movecircle.riv.d.ts b/example/assets/rive/movecircle.riv.d.ts index 229ae35c..34cb1c49 100644 --- a/example/assets/rive/movecircle.riv.d.ts +++ b/example/assets/rive/movecircle.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: movecircle.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/nodefaultbouncing.riv.d.ts b/example/assets/rive/nodefaultbouncing.riv.d.ts index 60e32056..b9abfda0 100644 --- a/example/assets/rive/nodefaultbouncing.riv.d.ts +++ b/example/assets/rive/nodefaultbouncing.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: nodefaultbouncing.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/on_entry_test.riv.d.ts b/example/assets/rive/on_entry_test.riv.d.ts index 56b087dc..b50e3df2 100644 --- a/example/assets/rive/on_entry_test.riv.d.ts +++ b/example/assets/rive/on_entry_test.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: on_entry_test.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/out_of_band.riv.d.ts b/example/assets/rive/out_of_band.riv.d.ts index 9546dc6a..aa987dcc 100644 --- a/example/assets/rive/out_of_band.riv.d.ts +++ b/example/assets/rive/out_of_band.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: out_of_band.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { Artboard: 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/quick_start.riv.d.ts b/example/assets/rive/quick_start.riv.d.ts index 4847c6a7..057517fe 100644 --- a/example/assets/rive/quick_start.riv.d.ts +++ b/example/assets/rive/quick_start.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: quick_start.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/rating.riv.d.ts b/example/assets/rive/rating.riv.d.ts index 35be7041..c234d3af 100644 --- a/example/assets/rive/rating.riv.d.ts +++ b/example/assets/rive/rating.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: rating.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { 'New Artboard': 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/rewards.riv.d.ts b/example/assets/rive/rewards.riv.d.ts index 85ed68a4..68adbe4f 100644 --- a/example/assets/rive/rewards.riv.d.ts +++ b/example/assets/rive/rewards.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: rewards.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/style_fallback_fonts.riv.d.ts b/example/assets/rive/style_fallback_fonts.riv.d.ts index ecd23149..bc1a66d1 100644 --- a/example/assets/rive/style_fallback_fonts.riv.d.ts +++ b/example/assets/rive/style_fallback_fonts.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: style_fallback_fonts.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -8,5 +8,6 @@ declare const asset: RiveAsset<{ stateMachines: { Artboard: 'State Machine 1'; }; + viewModels: {}; }>; export default asset; diff --git a/example/assets/rive/tabtest.riv.d.ts b/example/assets/rive/tabtest.riv.d.ts index 1752fd07..cc6448b3 100644 --- a/example/assets/rive/tabtest.riv.d.ts +++ b/example/assets/rive/tabtest.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: tabtest.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/example/assets/rive/viewmodelproperty.riv.d.ts b/example/assets/rive/viewmodelproperty.riv.d.ts index 949846e8..23cd8185 100644 --- a/example/assets/rive/viewmodelproperty.riv.d.ts +++ b/example/assets/rive/viewmodelproperty.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: viewmodelproperty.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ 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 d092fc1f..b5298568 100644 --- a/example/assets/rive/vm_value_change_test.riv.d.ts +++ b/example/assets/rive/vm_value_change_test.riv.d.ts @@ -1,5 +1,5 @@ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: vm_value_change_test.riv import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ diff --git a/scripts/rive-extract-schema.ts b/scripts/rive-extract-schema.ts index 6e14816f..d254564a 100644 --- a/scripts/rive-extract-schema.ts +++ b/scripts/rive-extract-schema.ts @@ -60,46 +60,24 @@ 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. + // Claim every referenced asset without decoding it. We only introspect + // 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 assetLoader = new (runtime as any).CustomFileAssetLoader({ - loadContents: (asset: any, embeddedBytes: Uint8Array) => { - if (embeddedBytes?.length && asset?.decode) { - asset.decode(embeddedBytes); - } - return true; - }, + loadContents: () => true, }); - const riveFile = await runtime.load(bytes, assetLoader, false); + let timer: ReturnType | undefined; + const riveFile = await Promise.race([ + runtime.load(bytes, assetLoader, false), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('load() timed out after 30000ms')), + 30_000 + ); + }), + ]).finally(() => clearTimeout(timer)); const artboards: string[] = []; const stateMachines: Record = {}; @@ -137,8 +115,17 @@ async function main() { try { const ep = inst.enum?.(p.name); const values: string[] = ep?.values ?? []; - props[p.name] = - values.length > 0 ? `enum:${values.join('|')}` : 'enum'; + // '|' is the separator in the 'enum:a|b' encoding — a value containing + // it cannot be represented, so fall back to an untyped enum. + if (values.some((v) => v.includes('|'))) { + process.stderr.write( + `Warning: enum property '${p.name}' has a value containing '|'; emitting untyped 'enum'.\n` + ); + props[p.name] = 'enum'; + } else { + props[p.name] = + values.length > 0 ? `enum:${values.join('|')}` : 'enum'; + } } catch { props[p.name] = 'enum'; } diff --git a/scripts/rive-gen-types.ts b/scripts/rive-gen-types.ts index 156ce065..06848d54 100644 --- a/scripts/rive-gen-types.ts +++ b/scripts/rive-gen-types.ts @@ -46,34 +46,14 @@ 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; - }); + runtimeReady = RuntimeLoader.awaitInstance(); } return runtimeReady; } +/** Per-file guard: a stalled WASM load() must fail loudly, never hang the batch. */ +const LOAD_TIMEOUT_MS = 30_000; + async function extractSchema(input: string): Promise { const bytes = input.startsWith('http://') || input.startsWith('https://') @@ -82,16 +62,24 @@ async function extractSchema(input: string): Promise { const runtime = await getRuntime(); + // Claim every referenced asset without decoding it. We only introspect + // 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. const assetLoader = new (runtime as any).CustomFileAssetLoader({ - loadContents: (asset: any, embeddedBytes: Uint8Array) => { - if (embeddedBytes?.length && asset?.decode) { - asset.decode(embeddedBytes); - } - return true; - }, + loadContents: () => true, }); - const riveFile = await runtime.load(bytes, assetLoader, false); + let timer: ReturnType | undefined; + const riveFile = await Promise.race([ + runtime.load(bytes, assetLoader, false), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`load() timed out after ${LOAD_TIMEOUT_MS}ms`)), + LOAD_TIMEOUT_MS + ); + }), + ]).finally(() => clearTimeout(timer)); const artboards: string[] = []; const stateMachines: Record = {}; @@ -128,8 +116,17 @@ async function extractSchema(input: string): Promise { try { const ep = inst.enum?.(p.name); const values: string[] = ep?.values ?? []; - props[p.name] = - values.length > 0 ? `enum:${values.join('|')}` : 'enum'; + // '|' is the separator in the 'enum:a|b' encoding — a value containing + // it cannot be represented, so fall back to an untyped enum. + if (values.some((v) => v.includes('|'))) { + process.stderr.write( + `Warning: enum property '${p.name}' has a value containing '|'; emitting untyped 'enum'.\n` + ); + props[p.name] = 'enum'; + } else { + props[p.name] = + values.length > 0 ? `enum:${values.join('|')}` : 'enum'; + } } catch { props[p.name] = 'enum'; } @@ -151,15 +148,19 @@ async function extractSchema(input: string): Promise { // 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); +// Rive names are free-form editor strings — escape for a single-quoted literal. +const escapeLiteral = (s: string) => + s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +const strLit = (s: string) => `'${escapeLiteral(s)}'`; const quoteKey = (s: string, forceQuote: boolean) => - forceQuote || needsQuote(s) ? `'${s}'` : s; + forceQuote || needsQuote(s) ? strLit(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'; + const union = sms.length ? sms.map(strLit).join(' | ') : 'never'; return ` ${quoteKey(ab, force)}: ${union};`; }) .join('\n'); @@ -175,7 +176,7 @@ function vmRecord(viewModels: Record>): string { const propLines = Object.entries(props) .map( ([propName, propType]) => - ` ${quoteKey(propName, forcePropKeys)}: '${propType}';` + ` ${quoteKey(propName, forcePropKeys)}: ${strLit(propType)};` ) .join('\n'); return ` ${quoteKey(vmName, forceVmKeys)}: {\n${propLines}\n };`; @@ -184,13 +185,15 @@ function vmRecord(viewModels: Record>): string { } function schemaBody(schema: Schema): string { + // Always emit viewModels — omitting it 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: {};'; return `\ - artboards: ${schema.artboards.map((a) => `'${a}'`).join(' | ')}; - defaultArtboard: '${schema.defaultArtboard}'; + artboards: ${schema.artboards.map(strLit).join(' | ')}; + defaultArtboard: ${strLit(schema.defaultArtboard)}; stateMachines: { ${smRecord(schema.stateMachines)} };${vmSection}`; @@ -199,7 +202,7 @@ ${smRecord(schema.stateMachines)} function dtsContent(input: string, schema: Schema): string { return `\ // Generated by rive-gen-types — do not edit manually. @generated -// eslint-disable +/* eslint-disable */ // Source: ${basename(input)} import type { RiveAsset } from '@rive-app/react-native'; declare const asset: RiveAsset<{ @@ -231,19 +234,10 @@ async function generate( 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); - } + const schema = await extractSchema(input); if (!schema.artboards?.length) { - process.stderr.write(`No artboards found in ${input}.\n`); - process.exit(1); + throw new Error(`No artboards found in ${input}.`); } const content = @@ -286,8 +280,24 @@ async function main() { process.stderr.write(`No .riv files found in ${dir}\n`); process.exit(1); } + // Process every file even if some fail, then report — a mid-batch abort + // would leave the remaining schemas silently unvalidated. + const failures: string[] = []; for (const file of files) { - await generate(file, `${file}.d.ts`, 'dts'); + try { + await generate(file, `${file}.d.ts`, 'dts'); + } catch (err) { + failures.push(file); + process.stderr.write( + `Failed: ${file}: ${err instanceof Error ? err.message : String(err)}\n` + ); + } + } + if (failures.length) { + process.stderr.write( + `${failures.length}/${files.length} file(s) failed.\n` + ); + process.exit(1); } return; } From dcc10de618157ec9871613b0dc3e6c4a76a4da7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Mon, 27 Jul 2026 14:23:26 +0200 Subject: [PATCH 3/3] test: pin batch coverage, emit escaping, and enum setter narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rive-gen-types --all regression test: every .riv must produce exactly one Written line and a .d.ts on disk — fails on the pre-fix generator (which silently wrote 6/29 before the event loop drained) and passes now; verified differentially against the old script. - Emit helpers (strLit/quoteKey/smRecord/vmRecord/schemaBody/enumTypeString) are exported and unit-tested with hostile names; the emitted body is syntax-checked by parsing with the TypeScript compiler, including a negative control showing the unescaped form is broken. main() is guarded by import.meta.main and the WASM shims moved inside it so importing the module for tests has no side effects. - The enum '|' fallback logic is extracted into enumTypeString and covered. - tsd pin: TypedViewModelEnumProperty narrows set and setValueAsync. --- scripts/__tests__/rive-gen-batch.test.ts | 31 +++++++ scripts/__tests__/rive-gen-emit.test.ts | 108 +++++++++++++++++++++++ scripts/rive-gen-types.ts | 77 +++++++++------- src/__tests__/typed-rive.test-d.ts | 7 ++ 4 files changed, 191 insertions(+), 32 deletions(-) create mode 100644 scripts/__tests__/rive-gen-batch.test.ts create mode 100644 scripts/__tests__/rive-gen-emit.test.ts diff --git a/scripts/__tests__/rive-gen-batch.test.ts b/scripts/__tests__/rive-gen-batch.test.ts new file mode 100644 index 00000000..b3e6a3c6 --- /dev/null +++ b/scripts/__tests__/rive-gen-batch.test.ts @@ -0,0 +1,31 @@ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import { resolve } from 'path'; +import { readdirSync, existsSync } from 'fs'; + +const GENERATOR = resolve(__dirname, '../rive-gen-types.ts'); +const ASSETS_DIR = resolve(__dirname, '../../example/assets/rive'); + +describe('rive-gen-types --all', () => { + // Regression: an asset whose WASM load() stalls used to drain bun's event + // loop mid-batch — the process exited 0 after writing only a prefix of the + // schemas, so CI silently validated a subset (many_viewmodels.riv was the + // in-tree trigger). Every .riv must produce exactly one Written line. + test('writes a schema for every .riv file (no silent truncation)', () => { + const result = spawnSync('bun', [GENERATOR, '--all', ASSETS_DIR], { + encoding: 'utf8', + timeout: 300_000, + cwd: resolve(__dirname, '../..'), + }); + expect(result.status).toBe(0); + + const rivFiles = readdirSync(ASSETS_DIR).filter((f) => f.endsWith('.riv')); + const written = (result.stdout ?? '') + .split('\n') + .filter((l) => l.startsWith('Written: ')); + expect(written.length).toBe(rivFiles.length); + for (const riv of rivFiles) { + expect(existsSync(resolve(ASSETS_DIR, `${riv}.d.ts`))).toBe(true); + } + }, 300_000); +}); diff --git a/scripts/__tests__/rive-gen-emit.test.ts b/scripts/__tests__/rive-gen-emit.test.ts new file mode 100644 index 00000000..5aebe597 --- /dev/null +++ b/scripts/__tests__/rive-gen-emit.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect } from 'bun:test'; +import ts from 'typescript'; +import { + strLit, + quoteKey, + smRecord, + vmRecord, + schemaBody, + enumTypeString, + type Schema, +} from '../rive-gen-types'; + +describe('emit escaping', () => { + test('strLit escapes quotes and backslashes', () => { + expect(strLit('plain')).toBe("'plain'"); + expect(strLit("O'Brien")).toBe("'O\\'Brien'"); + expect(strLit('back\\slash')).toBe("'back\\\\slash'"); + expect(strLit("both\\'")).toBe("'both\\\\\\''"); + }); + + test('quoteKey quotes and escapes non-identifier keys', () => { + expect(quoteKey('Identifier_1', false)).toBe('Identifier_1'); + expect(quoteKey('Has Space', false)).toBe("'Has Space'"); + expect(quoteKey("Player's Board", false)).toBe("'Player\\'s Board'"); + expect(quoteKey('Identifier_1', true)).toBe("'Identifier_1'"); + }); + + test('smRecord escapes artboard and state machine names', () => { + const out = smRecord({ "Art'board": ["State'Machine"] }); + expect(out).toBe(" 'Art\\'board': 'State\\'Machine';"); + }); + + test('vmRecord escapes VM names, property names, and type strings', () => { + const out = vmRecord({ + "VM's": { "prop's": "viewModel:Ref'd" }, + }); + expect(out).toContain("'VM\\'s': {"); + expect(out).toContain("'prop\\'s': 'viewModel:Ref\\'d';"); + }); + + test('emitted body with hostile names parses as valid TypeScript', () => { + const schema: Schema = { + artboards: ["O'Brien", 'back\\slash'], + defaultArtboard: "O'Brien", + stateMachines: { "O'Brien": ["It's SM"], 'back\\slash': [] }, + viewModels: { + "It's VM": { "quote'": "enum:a'b" }, + }, + }; + const body = schemaBody(schema); + expect(parseErrors(`declare const asset: {\n${body}\n};`)).toEqual([]); + expect(body).toContain("'O\\'Brien'"); + expect(body).toContain("'back\\\\slash'"); + + // Unescaped, the same names produce a syntactically broken declaration — + // this is what the generator used to emit. + expect( + parseErrors(`declare const asset: { artboards: 'O'Brien' };`) + ).not.toEqual([]); + }); +}); + +function parseErrors(code: string): string[] { + const sf = ts.createSourceFile('x.d.ts', code, ts.ScriptTarget.Latest); + const diags = (sf as unknown as { parseDiagnostics: ts.Diagnostic[] }) + .parseDiagnostics; + return diags.map((d) => + typeof d.messageText === 'string' + ? d.messageText + : d.messageText.messageText + ); +} + +describe('schemaBody', () => { + const base: Schema = { + artboards: ['Main'], + defaultArtboard: 'Main', + stateMachines: { Main: ['SM'] }, + viewModels: {}, + }; + + test('always emits viewModels, empty object when none', () => { + expect(schemaBody(base)).toContain('viewModels: {};'); + }); + + test('emits viewModels record when present', () => { + const body = schemaBody({ + ...base, + viewModels: { VM: { count: 'number' } }, + }); + expect(body).toContain("count: 'number';"); + expect(body).not.toContain('viewModels: {};'); + }); +}); + +describe('enumTypeString', () => { + test('joins values with |', () => { + expect(enumTypeString('p', ['a', 'b'])).toBe('enum:a|b'); + }); + + test('empty values fall back to untyped enum', () => { + expect(enumTypeString('p', [])).toBe('enum'); + }); + + test("a value containing the '|' separator falls back to untyped enum", () => { + expect(enumTypeString('p', ['a|b', 'c'])).toBe('enum'); + }); +}); diff --git a/scripts/rive-gen-types.ts b/scripts/rive-gen-types.ts index 06848d54..76827e8c 100644 --- a/scripts/rive-gen-types.ts +++ b/scripts/rive-gen-types.ts @@ -23,19 +23,23 @@ import { 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 {}; +// Called from main() so that importing this module (for unit-testing the +// exported emit helpers) has no global side effects. +function setupWasmShims(): void { + // 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'); + // 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 { +export interface Schema { artboards: string[]; defaultArtboard: string; stateMachines: Record; @@ -115,18 +119,7 @@ async function extractSchema(input: string): Promise { } else if (p.type === 'enumType' && inst) { try { const ep = inst.enum?.(p.name); - const values: string[] = ep?.values ?? []; - // '|' is the separator in the 'enum:a|b' encoding — a value containing - // it cannot be represented, so fall back to an untyped enum. - if (values.some((v) => v.includes('|'))) { - process.stderr.write( - `Warning: enum property '${p.name}' has a value containing '|'; emitting untyped 'enum'.\n` - ); - props[p.name] = 'enum'; - } else { - props[p.name] = - values.length > 0 ? `enum:${values.join('|')}` : 'enum'; - } + props[p.name] = enumTypeString(p.name, ep?.values ?? []); } catch { props[p.name] = 'enum'; } @@ -151,11 +144,11 @@ const needsQuote = (s: string) => !IDENTIFIER_RE.test(s); // Rive names are free-form editor strings — escape for a single-quoted literal. const escapeLiteral = (s: string) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); -const strLit = (s: string) => `'${escapeLiteral(s)}'`; -const quoteKey = (s: string, forceQuote: boolean) => +export const strLit = (s: string) => `'${escapeLiteral(s)}'`; +export const quoteKey = (s: string, forceQuote: boolean) => forceQuote || needsQuote(s) ? strLit(s) : s; -function smRecord(stateMachines: Record): string { +export function smRecord(stateMachines: Record): string { const keys = Object.keys(stateMachines); const force = keys.some(needsQuote); return Object.entries(stateMachines) @@ -166,7 +159,9 @@ function smRecord(stateMachines: Record): string { .join('\n'); } -function vmRecord(viewModels: Record>): string { +export function vmRecord( + viewModels: Record> +): string { const vmKeys = Object.keys(viewModels); const forceVmKeys = vmKeys.some(needsQuote); return Object.entries(viewModels) @@ -184,7 +179,7 @@ function vmRecord(viewModels: Record>): string { .join('\n'); } -function schemaBody(schema: Schema): string { +export function schemaBody(schema: Schema): string { // Always emit viewModels — omitting it would fail the RiveFileSchema // constraint and silently degrade the whole asset to untyped. const vmSection = @@ -265,7 +260,23 @@ function findRivFiles(dir: string): string[] { // --- CLI --- +/** + * Schema type string for an enum property. '|' is the separator in the + * 'enum:a|b' encoding — a value containing it cannot be represented, so fall + * back to an untyped enum. + */ +export function enumTypeString(propName: string, values: string[]): string { + if (values.some((v) => v.includes('|'))) { + process.stderr.write( + `Warning: enum property '${propName}' has a value containing '|'; emitting untyped 'enum'.\n` + ); + return 'enum'; + } + return values.length > 0 ? `enum:${values.join('|')}` : 'enum'; +} + async function main() { + setupWasmShims(); // noUncheckedIndexedAccess: slice gives string[], index access gives string | undefined const args: string[] = process.argv.slice(2); @@ -339,7 +350,9 @@ async function main() { } } -main().catch((err: Error) => { - process.stderr.write(err.message + '\n'); - process.exit(1); -}); +if (import.meta.main) { + 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 index 5c414ff8..1596ec05 100644 --- a/src/__tests__/typed-rive.test-d.ts +++ b/src/__tests__/typed-rive.test-d.ts @@ -218,6 +218,13 @@ expectType | undefined>( storeVM.viewModel('property of pegVM')?.enumProperty('pegType') ); +// All enum property setters are narrowed to the declared values +declare const pegTypeProp: TypedViewModelEnumProperty<'normal' | 'multiplier'>; +pegTypeProp.set('normal'); +expectType>(pegTypeProp.setValueAsync('multiplier')); +expectError(pegTypeProp.set('bogus')); +expectError(pegTypeProp.setValueAsync('bogus')); + // Non-enum property rejected for enumProperty() expectError(storeVM.enumProperty('xbuttonClick'));