feat: type-safe .riv schema system - #352
Conversation
e5be8e3 to
6e823df
Compare
Stacked on #352 (git-town: main → feat/type-safe-riv-schema-2 → this). Addresses the findings from a fresh review of the schema system; every finding was reproduced with compile probes before fixing, and each fix is pinned by new tsd tests. Type level (`useViewModelInstance`, `TypedViewModelInstance`): 1. Untyped files no longer dead-end. Everything from `RiveFileFactory.*` / `useRiveFile(require(...))` carries the base `RiveFileSchema`, and the old typed overload turned their property paths into `never` while `__vmBrand` blocked the untyped hooks — existing code that compiles on main failed to compile. All name/path constraints now degrade to `string` when the schema isn't statically known. 2. `viewModelName` actually constrains: a typo on a schema-typed file is a hard error instead of silently falling through to the untyped overload (which disabled all downstream path checking). 3. `required: true` keeps its non-null narrowing on typed calls, and the sync (non-`async`) path stays `@deprecated` for typed calls too. 4. Nested paths: instance accessors and `useRiveEnum` now accept them like the sibling hooks, up to two hops (`'Coin/Property_Of_Item/Item_Selection'`), with enum value unions resolved through the path. 5. List elements are plain `ViewModelInstance` — the previous all-VMs union made every accessor parameter intersect to `never`. Generator/CI: 6. `rive-gen-types --all` was silently validating only a prefix of the assets: 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 loader now claims assets without decoding, each `load()` has a 30s watchdog, and the batch continues past failures and exits non-zero. `click-count.riv.d.ts` is new in this PR — it sat in the skipped tail, so its schema had never been generated and CI didn't notice. 7. Schemas with no ViewModels emit `viewModels: {}` — omitting the key failed the `RiveFileSchema` constraint and silently degraded those assets (7 of them) to fully untyped. 8. Names are escaped in emitted `.d.ts` files; enum values containing `|` fall back to untyped `enum` with a warning; the header uses a working `/* eslint-disable */`; the CI sync check also catches untracked `.riv.d.ts` files.
Stacked on #352 (git-town: main → feat/type-safe-riv-schema-2 → this). Addresses the findings from a fresh review of the schema system; every finding was reproduced with compile probes before fixing, and each fix is pinned by new tsd tests. Type level (`useViewModelInstance`, `TypedViewModelInstance`): 1. Untyped files no longer dead-end. Everything from `RiveFileFactory.*` / `useRiveFile(require(...))` carries the base `RiveFileSchema`, and the old typed overload turned their property paths into `never` while `__vmBrand` blocked the untyped hooks — existing code that compiles on main failed to compile. All name/path constraints now degrade to `string` when the schema isn't statically known. 2. `viewModelName` actually constrains: a typo on a schema-typed file is a hard error instead of silently falling through to the untyped overload (which disabled all downstream path checking). 3. `required: true` keeps its non-null narrowing on typed calls, and the sync (non-`async`) path stays `@deprecated` for typed calls too. 4. Nested paths: instance accessors and `useRiveEnum` now accept them like the sibling hooks, up to two hops (`'Coin/Property_Of_Item/Item_Selection'`), with enum value unions resolved through the path. 5. List elements are plain `ViewModelInstance` — the previous all-VMs union made every accessor parameter intersect to `never`. Generator/CI: 6. `rive-gen-types --all` was silently validating only a prefix of the assets: 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 loader now claims assets without decoding, each `load()` has a 30s watchdog, and the batch continues past failures and exits non-zero. `click-count.riv.d.ts` is new in this PR — it sat in the skipped tail, so its schema had never been generated and CI didn't notice. 7. Schemas with no ViewModels emit `viewModels: {}` — omitting the key failed the `RiveFileSchema` constraint and silently degraded those assets (7 of them) to fully untyped. 8. Names are escaped in emitted `.d.ts` files; enum values containing `|` fall back to untyped `enum` with a warning; the header uses a working `/* eslint-disable */`; the CI sync check also catches untracked `.riv.d.ts` files.
9aee375 to
3c3c6e8
Compare
There was a problem hiding this comment.
Pull request overview
Introduces a codegen + type-level “schema” system for .riv assets that surfaces artboard/state machine/ViewModel structure as TypeScript phantom types, enabling compile-time validation of names and property paths across RiveView, useRiveFile, useViewModelInstance, and the useRive* property hooks (with no runtime overhead). It also adds Bun-based generator/extractor scripts, commits regenerated .riv.d.ts files, and enforces schema freshness in CI.
Changes:
- Add
RiveFileSchema/RiveAsset/TypedRiveFileandTypedViewModelInstancetype system, plus schema-aware overloads forRiveView,useRiveFile,useViewModelInstance, and property hooks. - Add Bun scripts to extract/generate schemas (
rive-extract-schema,rive-gen-types) with test coverage, and CI validation that committed.riv.d.tsfiles are up to date. - Bump
@rive-app/canvasto2.39.0and regenerate/commit.riv.d.tsschemas for example assets.
Reviewed changes
Copilot reviewed 26 out of 61 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds locked resolution for @rive-app/canvas@2.39.0. |
| package.json | Adds rive-gen-types script, Bun script tests, and @rive-app/canvas devDependency. |
| tsconfig.json | Enables allowArbitraryExtensions (for .riv imports) and excludes script tests from TS build. |
| eslint.config.mjs | Ignores generated **/*.riv.d.ts files in linting. |
| .github/workflows/ci.yml | Adds CI step to regenerate and verify committed .riv.d.ts schema files are in sync. |
| .github/actions/setup/action.yml | Installs Bun in CI to run schema generator and script tests. |
| src/index.tsx | Re-exports new schema/typed-file/typed-instance type surface from the library entrypoint. |
| src/core/TypedRiveFile.ts | Defines RiveFileSchema, RiveAsset<T> branding, and TypedRiveFile<T> schema propagation. |
| src/core/TypedViewModelInstance.ts | Defines schema-driven path typing, enum value unions, and typed ViewModel instance interfaces. |
| src/core/RiveFile.ts | Adds schema-generic return types to RiveFileFactory methods and schema-carrying overloads for fromSource. |
| src/core/RiveView.tsx | Makes RiveView props generic over schema to constrain artboard/state machine names. |
| src/hooks/useRiveFile.ts | Adds typed overload: passing a generated RiveAsset<T> yields a TypedRiveFile<T>. |
| src/hooks/useViewModelInstance.ts | Adds schema-aware overloads that type instance based on file schema + viewModelName. |
| src/hooks/useRiveNumber.ts | Adds typed overload constraining path to number properties for a typed ViewModel instance. |
| src/hooks/useRiveString.ts | Adds typed overload constraining path to string properties for a typed ViewModel instance. |
| src/hooks/useRiveBoolean.ts | Adds typed overload constraining path to boolean properties for a typed ViewModel instance. |
| src/hooks/useRiveColor.ts | Adds typed overload constraining path to color properties for a typed ViewModel instance. |
| src/hooks/useRiveTrigger.ts | Adds typed overload constraining path to trigger properties for a typed ViewModel instance. |
| src/hooks/useRiveEnum.ts | Adds typed overload constraining path to enum properties and narrowing values to schema unions. |
| src/tests/typed-rive.test-d.ts | Adds tsd type-level tests validating schema propagation and path/name constraints. |
| scripts/rive-extract-schema.ts | Adds Bun CLI to extract .riv schema (artboards/SMs/ViewModels) to JSON. |
| scripts/rive-gen-types.ts | Adds Bun CLI to generate .riv.d.ts schema declarations and standalone schema types. |
| scripts/tests/rive-extract-schema.test.ts | Adds Bun tests for schema extractor behavior (incl. nested refs + enum extraction). |
| scripts/tests/rive-gen-types.test.ts | Adds Bun tests for .riv.d.ts generation next to an asset. |
| scripts/tests/rive-gen-emit.test.ts | Adds emitter/escaping tests ensuring hostile names still produce valid TS. |
| scripts/tests/rive-gen-batch.test.ts | Adds regression test ensuring --all generates for every .riv (no silent truncation). |
| example/src/exercisers/RiveDataBindingExample.tsx | Updates example to import a typed .riv asset and use schema-typed hooks and instances. |
| example/src/exercisers/FileSwitcher.tsx | Adds an exerciser for rapid file switching to stress dispose/render race conditions. |
| example/assets/rive/arbtboards-models-instances.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/artboard_db_test.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/blinko.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/bouncing_ball.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/click-count.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/counter.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/databinding.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/databinding_images.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/databinding_lists.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/fallback_fonts.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/font_fallback.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/GradientBorder.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/hello_world_text.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/inputglow.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/ios_android_layouts_demo_v01.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/layout_test.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/layouts_demo.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/many_viewmodels.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/movecircle.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/nodefaultbouncing.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/on_entry_test.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/out_of_band.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/quick_start.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/rating.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/rewards.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/style_fallback_fonts.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/tabtest.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/viewmodelproperty.riv.d.ts | Generated schema typings for this example asset. |
| example/assets/rive/vm_value_change_test.riv.d.ts | Generated schema typings for this example asset. |
Comments suppressed due to low confidence (1)
scripts/rive-gen-types.ts:344
- This error message suggests using --out with a ".riv.d.ts" path, but --out currently triggers standalone schema generation (not an asset default-export declaration). The example should point to a .ts output (or the CLI should be updated to support emitting .riv.d.ts to an explicit path).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…-out/--name, fix usage docs - fetch() for URL inputs now fails early with the HTTP status instead of handing error-page bytes to the WASM loader. - --out and --name without a value print a clear error instead of crashing on the non-null assertion. - Header and usage text no longer imply URLs write a sibling .riv.d.ts (they require --out), and the fromSource example includes the required referencedAssets argument.
- Seeded property-based fuzz (300 iterations, mulberry32): random schemas over a hostile alphabet (quotes, backslashes, newlines, unicode, the '|' and 'viewModel:' encoding metacharacters) must always emit TS that parses cleanly and round-trips every artboard name exactly. This found a real bug: escapeLiteral missed newlines/control characters, emitting unterminated string literals — now escaped via JSON.stringify. Verified differentially: the fuzzer fails in <10ms on the old escaping. - Corrupt-input test: random bytes and a truncated .riv must fail the batch loudly (per-file 'Failed:' + non-zero exit) while valid files in the same batch still generate. Found that load() resolves null for unparseable bytes — both scripts now throw a clear error instead of 'null is not an object'.
…dd expo typed demo Building an end-user demo in expo-example surfaced another silent fallback: RiveView's generic was constrained to RiveFileSchema only, so a file annotated with the documented TypedRiveFile<typeof asset> pattern failed the constraint, inference fell back to the base schema, and artboard/state-machine name checking silently disappeared (files obtained from useRiveFile inferred fine, which is why the tsd suite missed it). RiveViewProps/RiveView now accept RiveFileSchema | RiveAsset and normalize through SchemaOf, matching TypedRiveFile — pinned with tsd call-site tests. The expo-example demo shows the full user flow: generated .riv.d.ts for its assets, a typed-demo screen using typed RiveView names, viewModelName, nested number paths, and the enum value union, with live values on screen. Verified by sabotage: each of 5 name/path/value typos is a compile error.
…RefTypeString rive-wasm (as of 2.39.0) doesn't expose the referenced ViewModel's name on getProperties() the way it does enumName, so both scripts instantiated a default instance and asked the nested instance for its name inline. The workaround now lives in one documented, unit-tested function shared by both scripts — when rive-wasm ships property-level viewModelName, the swap is a one-line change in one place.
| with: | ||
| node-version-file: .nvmrc | ||
|
|
||
| - name: Setup Bun |
There was a problem hiding this comment.
Not against it, but just want to make sure we're intentional, as all of the current workflows locally require yarn.
There was a problem hiding this comment.
It's kind of historic, we have node 20 in nvmrc it cannot run .ts directly. And for development running .ts directly without build step was essential. But newer Node is a real option 22.23.1 or 24.15.0+. So bun is not really needed - will remove from the PR.
HayesGordon
left a comment
There was a problem hiding this comment.
This is amazing!! A lot of people will be really excited for this. A feature request is to also generate asset names (images, fonts, audio) for out-of-band handling. Some questions around using Bun, and wanting to understand the decision around it in case it comes up as a question/restraint.
Bun stays the default dev runner, but nothing requires it: the scripts are erasable TypeScript, so Node >= 23.6 (or 22.6+ with --experimental-strip-types) runs them directly. Two portability fixes: default-import @rive-app/canvas (CJS named exports are invisible to Node's ESM loader), and an explicit .ts extension on the cross-script import (allowImportingTsExtensions, valid with noEmit). The entry-point guard falls back to an argv comparison where import.meta.main is unavailable (Node < 24.2). Verified: both scripts produce identical output under bun and node, all 29 schemas byte-identical.
…citly The scripts are only ever invoked as 'yarn rive-gen-types', 'bun scripts/…', or 'node scripts/…' (they aren't executable and aren't shipped as a bin), so the shebang was a misleading runtime commitment. Both bun and Node >= 22.18 run them; see the usage header.
Supersedes #294 (same feature, re-based onto main after the experimental-runtime merge in #134; squashed there since every commit conflicted with the runtime rewrite).
Codegen pipeline that extracts artboard/state machine/ViewModel schemas from
.rivfiles and surfaces them as TypeScript phantom types — no runtime overhead.yarn rive-gen-types example/assets/rive/rewards.riv(or--all) emits a.riv.d.tsnext to each asset; importing the asset then constrainsartboardName/stateMachineName,viewModelName, property paths per kind, and enum value unions acrossRiveView,useRiveFile,useViewModelInstance, and theuseRive*property hooks. See #294 for the full API walkthrough.Changes relative to #294:
RiveFileFactorykeepsgetBackend(), theloadCdndeprecation warning and optionalloadCdn?signatures, now with the typed generics on top;RiveViewkeeps the new docs/frameRatewhile its props are generic over the schema.@rive-app/canvasto 2.39.0 and regenerated all schemas — output is byte-identical, so the extractor works with the new runtime as is.tabtest.riv,arbtboards-models-instances.riv).Note: rive-wasm 2.39.0 now exposes
ViewModelProperty.enumName, so the "enum names not accessible" limitation from #294 is gone — a follow-up can emit named enum types instead of inlined value unions.