Skip to content

feat: type-safe .riv schema system#294

Open
mfazekas wants to merge 24 commits into
mainfrom
feat/type-safe-riv-schema
Open

feat: type-safe .riv schema system#294
mfazekas wants to merge 24 commits into
mainfrom
feat/type-safe-riv-schema

Conversation

@mfazekas

Copy link
Copy Markdown
Collaborator

Overview

Codegen pipeline that extracts artboard/state machine/ViewModel schemas from .riv files and surfaces them as TypeScript phantom types — no runtime overhead.

How it works

Step 1 — generate .riv.d.ts alongside each asset (committed to git):

yarn rive-gen-types example/assets/rive/rewards.riv
yarn rive-gen-types --all example/assets/rive/  # batch

Uses @rive-app/canvas WASM to introspect the file and emits a declaration:

declare const asset: RiveAsset<{
  artboards: 'Main' | 'Lives 2';
  defaultArtboard: 'Main';
  stateMachines: { 'Main': 'State Machine 1' };
  viewModels: {
    'Rewards': {
      'Price_Value': 'number';
      'favourite_pet': 'enum:chipmunk|rat|frog|owl|cat|dog';
      'Coin': 'viewModel:Item_Icon_Value';
    };
  };
}>;
export default asset;

Step 2 — use the typed asset:

import rewardsRiv from './assets/rive/rewards.riv';

// TypedRiveFile<T> — accepts RiveAsset directly, schema flows through
const { riveFile } = useRiveFile(rewardsRiv);

// Artboard/SM names constrained to schema
<RiveView file={riveFile} artboardName="Main" stateMachineName="State Machine 1" />

// viewModelName constrained to schema VMs
const { instance } = useViewModelInstance(riveFile, { viewModelName: 'Rewards' });

// Property paths constrained to the correct kind
useRiveNumber('Price_Value', instance);          // ✓
useRiveNumber('Coin', instance);                 // ✗ — Coin is a viewModel ref, not number
useRiveNumber('Coin/Item_Value', instance);      // ✓ — nested path
useRiveTrigger('Coin/Item_Value', instance);     // ✗ — Item_Value is number, not trigger

// Enum values constrained to the declared set
const { value } = useRiveEnum('favourite_pet', instance);
//    value: 'chipmunk' | 'rat' | 'frog' | 'owl' | 'cat' | 'dog'

// Inline types — no intermediate type aliases needed
function MyComponent({ file }: { file: TypedRiveFile<typeof rewardsRiv> }) { ... }
type RewardsInstance = TypedViewModelOf<typeof rewardsRiv, 'Rewards'>;

What's included

  • scripts/rive-extract-schema.ts — WASM-based extractor; resolves nested VM refs and extracts enum values
  • scripts/rive-gen-types.ts — generates .riv.d.ts, single file or --all batch
  • src/core/TypedRiveFile.tsRiveAsset<T>, TypedRiveFile<T>, SchemaOf<T>
  • src/core/TypedViewModelInstance.tsTypedViewModelInstance<T,N>, TypedViewModelOf<T,N>, UntypedViewModelInstance, PathsOfKind, EnumValuesOf
  • Typed overloads on RiveView, useRiveFile, useViewModelInstance, useRiveNumber/String/Boolean/Color/Enum/Trigger
  • __vmBrand phantom property on TypedViewModelInstance — prevents typed instances from silently matching untyped hook overloads
  • src/__tests__/typed-rive.test-d.ts — tsd type-level tests against real .riv.d.ts files
  • CI step that regenerates all schemas and fails if any .riv.d.ts is out of date

Notes

  • .riv.d.ts files are committed (same as nitrogen-generated bindings); CI enforces they stay in sync
  • Requires allowArbitraryExtensions: true in tsconfig for .riv imports to resolve their .riv.d.ts
  • Enum names are not accessible from the WASM JS layer (only values are); a native schemaToJSON() in the C++ runtime would fix this cleanly

mfazekas added 24 commits June 17, 2026 15:49
Adds a codegen pipeline that extracts artboard/SM/ViewModel schemas from .riv files at build time and exposes them as TypeScript phantom types. RiveView, useRiveFile, useRiveNumber/String/Boolean/Color/Trigger, and useViewModelInstance all gain typed overloads so wrong artboard names, state machine names, or property paths are caught at compile time.

Scripts: rive-extract-schema.ts (WASM-based extractor), rive-gen-types.ts (generates .riv.d.ts alongside assets), with bun tests for both. Type-level tests via tsd in src/__tests__/typed-rive.test-d.ts.
…lInstance

Adds __vmBrand to TypedViewModelInstance so TypeScript rejects passing a typed
instance to untyped hook overloads, ensuring wrong property paths are caught at
compile time. Also adds allowArbitraryExtensions to tsconfig for .riv imports.
…t-schema

Patch makeRenderImage to wrap img.decode so img.la() fires via queueMicrotask
after the WASM K() returns (calling it synchronously resolves the Promise with
null since H hasn't been assigned yet). Also pass actual embedded bytes through
CustomFileAssetLoader so fonts and images are decoded, not just marked handled.
…ocess

Spawning one bun process per .riv file via spawnSync was flaky under CI load:
processes occasionally exited 0 with empty stdout due to resource contention.
Now the WASM runtime is initialised once and all files are processed in the
same process, eliminating the subprocess entirely.
@mfazekas mfazekas force-pushed the feat/type-safe-riv-schema branch from d3b12da to a5120ce Compare June 17, 2026 13:49
mfazekas added a commit that referenced this pull request Jul 10, 2026
Replace the session-invented eslint typetest convention with the tsd
infrastructure PR #294 introduces, copied as closely as possible so
whichever PR merges second reconciles trivially: tsd ^0.33.0, the
'typetest' script (tsd --typings src/index.tsx), the package.json tsd
block (directory src/__tests__, paths/moduleResolution/jsx options),
jest testPathIgnorePatterns for *.test-d.ts, eslint + root-tsconfig
excludes for *.test-d.ts.

The pins live in src/__tests__/useViewModelInstance.test-d.ts as
explicit assertions: expectDeprecated for every call that must resolve
to a deprecated overload (bare, async: false, widened boolean async,
exported param types), expectNotDeprecated for the literal async: true
forms including the { ...params, async: true } re-pin, expectType for
the required-narrowing behavior, and expectError for invalid param
combinations. Verified that tsd resolves deprecation per-overload for
call expressions (failure messages name the resolved signature).

One deliberate divergence from #294: CI gates 'yarn typetest' in the
lint job (theirs wires the script without a CI step) — keep the step
when the branches meet.
mfazekas added a commit that referenced this pull request Jul 10, 2026
…: true }) (#304)

## What

Adds an `async: true` param to `useViewModelInstance` that creates the
ViewModelInstance via the non-deprecated `*Async` runtime APIs,
resolving off the JS thread. The result gains an `isLoading` state (the
sync path reports it too, so both modes share one result shape). The
non-async overloads are deprecated per-overload and `async: true`
becomes the default in the next major.

## Why

The sync creation path uses `@deprecated` runtime APIs that block the JS
thread. Keeping a single hook name (instead of a separate
`useViewModelInstanceAsync`) makes migration a param flip, keeps parity
with `@rive-app/react`, and lets the default change at the next major
without a rename.

## Notes

- `async` selects between two hook implementations and must stay
constant for a component's lifetime (documented on the param; a dev-mode
`console.error` explains a flip before React's hooks-order invariant
fires).
- Deprecation coverage is total: any call not provably `async: true` —
including params objects whose `async` widened to `boolean`, or values
of the exported `*Params` types — resolves to a deprecated overload. The
message includes the fix: re-pin with `{ ...params, async: true }`.
- Overload/deprecation resolution is regression-pinned with tsd (`yarn
typetest`, `src/__tests__/useViewModelInstance.test-d.ts` —
`expectDeprecated`/`expectNotDeprecated`/`expectError`/`expectType`).
The wiring mirrors #294's verbatim (same dep, script, config) so
whichever PR merges second reconciles trivially; this PR additionally
gates the script in the CI lint job.
- In both modes, a `null` source settles terminally while `undefined`
keeps loading — mirroring `useRiveFile` and `useRive`.
- Companion fixes: friendly artboard/instance not-found errors with the
native rejection preserved as `cause` (and swallowed native failures
logged); the new backends resolve null for a VM-less default artboard
instead of surfacing a raw error (issue #189 fixture); a RiveViewRef
source polls briefly for the view's auto-bound instance instead of
settling a one-shot null; the intermediate ViewModel wrapper is disposed
after creation; on source change the hook resets during render so no
frame ever pairs the new source with the previous (about-to-be-disposed)
instance; `required` failures throw actionable messages (a null source
points at the upstream hook's error).
- Legacy Android thread-safety: `getViewModelInstance()` no longer
traverses controller state from the JS thread — it reads a
main-thread-maintained snapshot (refreshed eagerly when binding
changes), avoiding both the #297 race class and a JS↔main `runBlocking`
deadlock hazard.
- CI: the expo57 harness now runs the full shared suite
(`example/__tests__` had been silently dropped — coverage went from 4 to
26 suites) with attempt budgets resized to fit, and crash reports are
collected on the legacy iOS job too.
- Example screens migrated to `{ async: true }`.

## Breaking changes

- `useRive().riveViewRef` is `undefined` (not `null`) until the view is
ready; `null` now means failed/detached. Migrate `riveViewRef !== null`
checks to `!= null` (or `riveViewRef?.method()`), and widen any explicit
`RiveViewRef | null` annotations.
- On the sync (non-async, deprecated) path a `null` source now settles
to `{ instance: null, isLoading: false }` instead of a perpetual
`instance: undefined`, and `required: true` throws for it — matching the
async semantics.
- `UseViewModelInstanceResult` gained an `isLoading` field (additive for
consumers, breaking only for exact-shape type assertions).
- Legacy backends: the `*Async` runtime lookups now execute on the main
thread — "async" means "doesn't block the JS thread", never "parallel
with rendering".

## Verify

`yarn tsc`, `yarn lint`, `yarn typetest` (tsd), `yarn jest` (86 passed,
incl. committed-frame, overload-compat, and async-flip-guard regression
tests). On-device: the full restored harness suite (26 suites / 204
tests) green on all four CI jobs (iOS + Android × experimental +
legacy); the legacy `cause` divergence, the VM-less raw error, and the
ref-source race were each reproduced before fixing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant