diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1000828f..40cc9841 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,10 @@ jobs: - name: Typecheck files run: yarn typecheck + # Overload/deprecation pins (src/__tests__/*.test-d.ts). + - name: Type tests + run: yarn typetest + test: runs-on: ubuntu-latest steps: diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt b/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt index d1f87197..697e2d94 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -86,8 +86,13 @@ class HybridRiveFile : HybridRiveFileSpec() { } } + // The *Async lookups run on the main thread (via [riveMainScope], not Nitro's + // Dispatchers.Default pool): they walk the same RiveFile the attached views + // render on the main thread, and the legacy runtime has no internal + // synchronization — off-main access is the race class of issue #297. + // "Async" here means "doesn't block the JS thread". override fun getViewModelNamesAsync(): Promise> { - return Promise.async { + return Promise.async(riveMainScope) { val file = riveFile ?: return@async emptyArray() val count = file.viewModelCount val names = mutableListOf() @@ -103,19 +108,19 @@ class HybridRiveFile : HybridRiveFileSpec() { } override fun viewModelByNameAsync(name: String, validate: Boolean?): Promise { - return Promise.async { viewModelByName(name) } + return Promise.async(riveMainScope) { viewModelByName(name) } } override fun defaultArtboardViewModelAsync(artboardBy: ArtboardBy?): Promise { - return Promise.async { defaultArtboardViewModel(artboardBy) } + return Promise.async(riveMainScope) { defaultArtboardViewModel(artboardBy) } } override fun getArtboardCountAsync(): Promise { - return Promise.async { artboardCount } + return Promise.async(riveMainScope) { artboardCount } } override fun getArtboardNamesAsync(): Promise> { - return Promise.async { artboardNames } + return Promise.async(riveMainScope) { artboardNames } } override fun updateReferencedAssets(referencedAssets: ReferencedAssetsType) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt b/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt index f9e975fe..318ad099 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridRiveView.kt @@ -115,6 +115,8 @@ class HybridRiveView(val context: ThemedReactContext) : HybridRiveViewSpec() { } override fun getViewModelInstance(): HybridViewModelInstanceSpec? { + // Thread-safety lives in RiveReactNativeView.getViewModelInstance(): it + // returns a main-thread-maintained snapshot without blocking this thread. val viewModelInstance = view.getViewModelInstance() ?: return null return HybridViewModelInstance(viewModelInstance) } diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModel.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModel.kt index 0222cd40..a319a5a6 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModel.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModel.kt @@ -53,23 +53,25 @@ class HybridViewModel(private val viewModel: ViewModel) : HybridViewModelSpec() } } + // Main-hopped like HybridRiveFile's lookups — the legacy runtime is only + // safe to touch on the main thread (see riveMainScope). override fun getPropertyCountAsync(): Promise { - return Promise.async { propertyCount } + return Promise.async(riveMainScope) { propertyCount } } override fun getInstanceCountAsync(): Promise { - return Promise.async { instanceCount } + return Promise.async(riveMainScope) { instanceCount } } override fun createInstanceByNameAsync(name: String): Promise { - return Promise.async { createInstanceByName(name) } + return Promise.async(riveMainScope) { createInstanceByName(name) } } override fun createDefaultInstanceAsync(): Promise { - return Promise.async { createDefaultInstance() } + return Promise.async(riveMainScope) { createDefaultInstance() } } override fun createBlankInstanceAsync(): Promise { - return Promise.async { createInstance() } + return Promise.async(riveMainScope) { createInstance() } } } diff --git a/android/src/main/java/com/margelo/nitro/rive/RiveMainScope.kt b/android/src/main/java/com/margelo/nitro/rive/RiveMainScope.kt new file mode 100644 index 00000000..44c07dc9 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/rive/RiveMainScope.kt @@ -0,0 +1,13 @@ +package com.margelo.nitro.rive + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +// Legacy runtime objects are only safe to touch on the main thread: the +// attached views render there and rive-android's legacy API has no internal +// synchronization (off-main access is the race class of issue #297). This +// scope hops *Async calls onto main. It is intentionally never cancelled — +// a Promise launched on a cancelled scope would never settle, so bodies +// guard on disposed state instead. +internal val riveMainScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) diff --git a/android/src/main/java/com/rive/RiveReactNativeView.kt b/android/src/main/java/com/rive/RiveReactNativeView.kt index dd812908..3c27882a 100644 --- a/android/src/main/java/com/rive/RiveReactNativeView.kt +++ b/android/src/main/java/com/rive/RiveReactNativeView.kt @@ -110,6 +110,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (willDispose) { riveAnimationView?.dispose() removeEventListeners() + // Post-dispose reads must resolve null, not the retained (released) + // instance; also stops pinning it for the view's remaining lifetime. + lastKnownViewModelInstance = null } super.onDetachedFromWindow() } @@ -165,9 +168,27 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { if (!stateMachines.isNullOrEmpty()) { stateMachines.first().viewModelInstance = vmi } + // Keep the snapshot fresh at the moment binding changes so a JS-side + // read right after binding sees the instance without waiting for a + // posted refresh (guards the read-after-bind contract, issue #156). + lastKnownViewModelInstance = readViewModelInstanceOnMain() } - fun getViewModelInstance(): ViewModelInstance? { + // Cache maintained on the main thread: the controller's state-machine list + // is mutated there and the legacy runtime has no internal synchronization + // (issue #297 race class), so other threads must not traverse it. Off-main + // reads return the last main-thread snapshot and schedule a refresh — + // eventually consistent, which suits the polling callers (the async hook's + // ref path, harness waitFor loops). Blocking on the main thread instead + // would risk a JS↔main deadlock under the legacy bridge. + @Volatile + private var lastKnownViewModelInstance: ViewModelInstance? = null + + private fun readViewModelInstanceOnMain(): ViewModelInstance? { + // A refresh posted before dispose() can run after it; reading the + // torn-down controller's stale list here would re-cache a released + // instance right after onDetachedFromWindow cleared it. + if (willDispose) return null val stateMachines = riveAnimationView?.controller?.stateMachines return if (!stateMachines.isNullOrEmpty()) { stateMachines.first().viewModelInstance @@ -176,6 +197,17 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } + fun getViewModelInstance(): ViewModelInstance? { + if (android.os.Looper.myLooper() == android.os.Looper.getMainLooper()) { + lastKnownViewModelInstance = readViewModelInstanceOnMain() + return lastKnownViewModelInstance + } + android.os.Handler(android.os.Looper.getMainLooper()).post { + lastKnownViewModelInstance = readViewModelInstanceOnMain() + } + return lastKnownViewModelInstance + } + fun applyDataBinding(bindData: BindData) { bindToStateMachine(bindData) } @@ -360,6 +392,9 @@ class RiveReactNativeView(context: ThemedReactContext) : FrameLayout(context) { } } } + // Keep the snapshot fresh at the moment binding changes — see + // lastKnownViewModelInstance. + lastKnownViewModelInstance = readViewModelInstanceOnMain() } private fun convertEventProperties(properties: Map?): Map? { diff --git a/eslint.config.mjs b/eslint.config.mjs index ed770b41..66f18495 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,6 +55,12 @@ export default defineConfig([ 'lib/', '**/.expo/', '**/.harness/', + // tsd owns *.test-d.ts (yarn typetest) — deprecated calls there are + // intentional expectDeprecated() pins, not violations. + '**/*.test-d.ts', + // Agent worktrees (.claude/worktrees/*) are gitignored full-repo copies; + // linting them duplicates every finding and slows the run. + '**/.claude/', ], }, ]); diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index e8323b99..149a3b54 100644 --- a/example/__tests__/autoplay.harness.tsx +++ b/example/__tests__/autoplay.harness.tsx @@ -302,9 +302,17 @@ describe('Auto dataBind with no default ViewModel (issue #189)', () => { expect(context.error).toBeNull(); - const vmi = context.ref!.getViewModelInstance(); - expect(vmi).not.toBeNull(); - expect(vmi!.numberProperty('ypos')).toBeDefined(); + // The default ViewModel instance and its properties resolve asynchronously a + // short time after the view ref is assigned, so poll until the property is + // available rather than reading it the instant the ref exists. + await waitFor( + () => { + expect( + context.ref!.getViewModelInstance()?.numberProperty('ypos') + ).toBeDefined(); + }, + { timeout: 5000 } + ); cleanup(); }); diff --git a/example/__tests__/useViewModelInstance-async-e2e.harness.tsx b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx new file mode 100644 index 00000000..07a3b193 --- /dev/null +++ b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx @@ -0,0 +1,458 @@ +import { + describe, + it, + expect, + render, + waitFor, + cleanup, +} from 'react-native-harness'; +import { useEffect, useState } from 'react'; +import { Text, View } from 'react-native'; +import { + Fit, + RiveFileFactory, + RiveView, + useRive, + useRiveFile, + useViewModelInstance, + type RiveFile, + type RiveFileInput, + type RiveViewRef, + type ViewModelInstance, +} from '@rive-app/react-native'; + +// These run against the real native runtime on purpose: the Jest unit tests +// mock the *Async APIs, but the backends genuinely diverge — the experimental +// backend (feat/rive-ios-experimental) *rejects* on a bad artboard/instance +// name while this backend resolves null/undefined. These pin the hook's +// contract so it holds on both. +const MULTI_AB = require('../assets/rive/arbtboards-models-instances.riv'); +// ViewModels exist but no artboard default (issue #189 fixture). +const NO_DEFAULT_VM = require('../assets/rive/nodefaultbouncing.riv'); +// Default artboard auto-binds a default ViewModel. +const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv'); + +function expectDefined(value: T): asserts value is NonNullable { + // toBeDefined() alone passes for null, which would betray the NonNullable + // type claim this helper makes. + expect(value).toBeDefined(); + expect(value).not.toBeNull(); +} + +async function loadFile() { + return RiveFileFactory.fromSource(MULTI_AB, undefined); +} + +type AsyncCtx = { + instance: ViewModelInstance | null | undefined; + error: Error | null; + isLoading: boolean; +}; + +function createCtx(): AsyncCtx { + return { instance: undefined, error: null, isLoading: true }; +} + +function ArtboardProbe({ + file, + artboardName, + ctx, +}: { + file: RiveFile; + artboardName: string; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstance(file, { + async: true, + artboardName, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +function ViewModelProbe({ + file, + viewModelName, + onInit, + ctx, +}: { + file: RiveFile; + viewModelName: string; + onInit?: (vmi: ViewModelInstance) => void; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstance(file, { + async: true, + viewModelName, + onInit, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +function InstanceNameProbe({ + file, + viewModelName, + instanceName, + ctx, +}: { + file: RiveFile; + viewModelName: string; + instanceName: string; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstance(file, { + async: true, + viewModelName, + instanceName, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +function FixedSourceProbe({ + source, + ctx, +}: { + source: RiveFile | null | undefined; + ctx: AsyncCtx; +}) { + const { instance, error, isLoading } = useViewModelInstance(source, { + async: true, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +type FileErrorCtx = AsyncCtx & { fileErrored: boolean }; + +function FileErrorProbe({ + input, + ctx, +}: { + input: RiveFileInput | undefined; + ctx: FileErrorCtx; +}) { + const { riveFile, error: fileError } = useRiveFile(input); + const { instance, error, isLoading } = useViewModelInstance(riveFile, { + async: true, + }); + useEffect(() => { + ctx.fileErrored = fileError != null; + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, fileError, instance, error, isLoading]); + return ( + + {String(isLoading)} + + ); +} + +// ── #1: unknown artboard name maps to the friendly not-found error ─── +// This backend resolves undefined; the experimental backend throws (iOS +// `createArtboard`, Android `Artboard.fromFile`). The hook must map both to +// the same friendly error instead of leaking a raw native message. + +describe('useViewModelInstance async: unknown artboard name', () => { + it('surfaces the friendly "not found" error instead of the raw native message', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expectDefined(ctx.error); + // Exact match: the raw native rejection message differs, so this fails if + // the friendly-error mapping is removed and the native message leaks. + expect(ctx.error.message).toBe( + "Artboard 'doesNotExist' not found or has no ViewModel" + ); + cleanup(); + }); + + it('resolves an instance for a valid artboard name (control)', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.instance).toBeTruthy(), { timeout: 5000 }); + expect(ctx.error).toBeNull(); + cleanup(); + }); +}); + +// ── #3: onInit failures are surfaced, not swallowed ────────────────── +// QuickStart gated its RiveView on `instance` alone and ignored `error`, so a +// throwing onInit silently blank-screened. These lock the contract the fixed +// example now relies on: a throw becomes `error`, a clean onInit resolves. + +describe('useViewModelInstance async: onInit', () => { + it('surfaces an onInit throw as error and leaves instance null', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + { + throw new Error('init boom'); + }} + ctx={ctx} + /> + ); + await waitFor(() => expect(ctx.error).not.toBeNull(), { timeout: 5000 }); + expect(ctx.error!.message).toBe('init boom'); + expect(ctx.instance).toBeNull(); + cleanup(); + }); + + it('runs a clean onInit against the real instance and resolves (control)', async () => { + const file = await loadFile(); + const ctx = createCtx(); + let seenId: string | undefined; + await render( + { + seenId = vmi.stringProperty('_id')?.value; + }} + ctx={ctx} + /> + ); + await waitFor(() => expect(ctx.instance).toBeTruthy(), { timeout: 5000 }); + expect(ctx.error).toBeNull(); + expect(seenId).toBe('vm1.vmi.id'); + cleanup(); + }); +}); + +// ── #2: a null (errored/absent) source must not spin forever ───────── +// useRiveFile returns `undefined` while loading but `null` once it errors. The +// hook must keep `undefined` in the loading state (file still resolving) yet +// terminate on `null`, otherwise a consumer keying a spinner off `isLoading` +// hangs forever with no signal. + +describe('useViewModelInstance async: null vs undefined source', () => { + it('stays loading while the source is undefined (file still resolving)', async () => { + const ctx = createCtx(); + await render(); + // Give any async resolution a chance to (incorrectly) settle. + await new Promise((r) => setTimeout(r, 500)); + expect(ctx.isLoading).toBe(true); + expect(ctx.instance).toBeUndefined(); + expect(ctx.error).toBeNull(); + cleanup(); + }); + + it('terminates (not loading) when the source is null', async () => { + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 3000 }); + expect(ctx.instance).toBeNull(); + expect(ctx.error).toBeNull(); + cleanup(); + }); + + it('terminates when useRiveFile fails to load the file', async () => { + const ctx: FileErrorCtx = { ...createCtx(), fileErrored: false }; + // `undefined` input makes useRiveFile resolve to { riveFile: null, error }, + // the same terminal shape a real load failure produces. + await render(); + await waitFor(() => expect(ctx.fileErrored).toBe(true), { timeout: 3000 }); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 3000 }); + expect(ctx.instance).toBeNull(); + cleanup(); + }); +}); + +// ── Instance creation failure keeps a clean message + native cause ─── +// The stable contract is the message: always "… not found", never a raw +// native error. `cause` is an optional diagnostic — present when the backend +// *rejects* (the experimental iOS backend throws `invalidViewModelInstance`), +// absent when the lookup resolves null (this backend, and Android's +// `contains()` pre-check). Asserting a cause per-platform would pin that +// accidental divergence, so only its shape is checked when present. + +describe('useViewModelInstance async: instance creation failure', () => { + it('keeps a clean not-found message on every backend (cause optional)', async () => { + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expectDefined(ctx.error); + // Message stays clean and stable on every platform and backend. + expect(ctx.error.message).toContain('not found'); + expect(ctx.error.message).not.toContain('Could not create'); + const cause = (ctx.error as Error & { cause?: unknown }).cause; + if (cause !== undefined) { + // A rejecting backend must preserve the native diagnostic, not lose it. + expect(String((cause as Error).message).length).toBeGreaterThan(0); + } + cleanup(); + }); + + it('reports not-found when the instance genuinely resolves to null', async () => { + // A ViewModel with no such named instance that resolves (not throws) must + // still read as "not found" — the branch reserved for the null case. + const file = await loadFile(); + const ctx = createCtx(); + await render( + + ); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expectDefined(ctx.error); + // The stable message, not a leaked raw native diagnostic. + expect(ctx.error.message).toContain('not found'); + expect(ctx.error.message).not.toContain('Could not create'); + cleanup(); + }); +}); + +function RefSourceConsumer({ file, ctx }: { file: RiveFile; ctx: AsyncCtx }) { + const { riveViewRef, setHybridRef } = useRive(); + const { instance, error, isLoading } = useViewModelInstance(riveViewRef, { + async: true, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + + + ); +} + +function EagerRefConsumer({ file, ctx }: { file: RiveFile; ctx: AsyncCtx }) { + // Raw hybridRef: the ref is exposed the moment the native view attaches, + // before awaitViewReady/auto-bind complete — the widest race window. + const [viewRef, setViewRef] = useState(undefined); + const { instance, error, isLoading } = useViewModelInstance(viewRef, { + async: true, + }); + useEffect(() => { + ctx.instance = instance; + ctx.error = error; + ctx.isLoading = isLoading; + }, [ctx, instance, error, isLoading]); + return ( + + setViewRef(ref ?? undefined), + }} + style={{ flex: 1 }} + file={file} + autoPlay={true} + fit={Fit.Contain} + /> + + ); +} + +// ── RiveViewRef source: the auto-bound instance must be delivered ──── +// The auto-bound ViewModelInstance resolves asynchronously a short time after +// the view ref is assigned (see autoplay.harness.tsx), so a one-shot +// getViewModelInstance() read races the bind and can settle a terminal +// { instance: null } on fast mounts. + +describe('useViewModelInstance async: RiveViewRef source', () => { + it('resolves the auto-bound instance from a useRive view ref', async () => { + const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 10000 }); + expect(ctx.error).toBeNull(); + expect(ctx.instance).toBeTruthy(); + cleanup(); + }); + + it('resolves the auto-bound instance from a raw hybridRef (pre-bind window)', async () => { + const file = await RiveFileFactory.fromSource(BOUNCING_BALL, undefined); + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 10000 }); + expect(ctx.error).toBeNull(); + expect(ctx.instance).toBeTruthy(); + cleanup(); + }); +}); + +// ── A VM-less default artboard is "no ViewModel", not an error ─────── +// A file whose default artboard has no ViewModel (the issue #189 fixture) is +// a valid "no ViewModel" state, not a failure: this backend resolves null +// natively, and the experimental backend normalizes its +// getDefaultViewModelInfo throw to the same resolve-null, so the hook's +// documented { instance: null, error: null } state is reachable on every +// backend. + +describe('useViewModelInstance async: file without a default ViewModel', () => { + it('resolves null with no error', async () => { + const file = await RiveFileFactory.fromSource(NO_DEFAULT_VM, undefined); + const ctx = createCtx(); + await render(); + await waitFor(() => expect(ctx.isLoading).toBe(false), { timeout: 5000 }); + expect(ctx.instance).toBeNull(); + expect(ctx.error).toBeNull(); + cleanup(); + }); +}); diff --git a/example/assets/rive/arbtboards-models-instances.riv b/example/assets/rive/arbtboards-models-instances.riv new file mode 100644 index 00000000..d5e3e181 Binary files /dev/null and b/example/assets/rive/arbtboards-models-instances.riv differ diff --git a/example/src/__tests__/issue297.hooks.test.tsx b/example/src/__tests__/issue297.hooks.test.tsx index a8ec925a..5e4c9c52 100644 --- a/example/src/__tests__/issue297.hooks.test.tsx +++ b/example/src/__tests__/issue297.hooks.test.tsx @@ -36,6 +36,7 @@ function AgeHook({ instance, ctx }: { instance: ViewModelInstance; ctx: Ctx }) { } function Repro({ file, ctx }: { file: RiveFile; ctx: Ctx }) { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- #297 regression rides the sync creation path on purpose const { instance } = useViewModelInstance(file); const [hookKey, setHookKey] = useState(0); useEffect(() => { diff --git a/example/src/__tests__/staleSetterWrite.test.tsx b/example/src/__tests__/staleSetterWrite.test.tsx index c29ad877..e304cc25 100644 --- a/example/src/__tests__/staleSetterWrite.test.tsx +++ b/example/src/__tests__/staleSetterWrite.test.tsx @@ -34,6 +34,7 @@ function AgeHook({ instance, ctx }: { instance: ViewModelInstance; ctx: Ctx }) { } function Repro({ file, ctx }: { file: RiveFile; ctx: Ctx }) { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- stale-write regression rides the sync creation path on purpose const { instance } = useViewModelInstance(file); if (!instance) return null; return ( diff --git a/example/src/demos/DataBindingArtboardsExample.tsx b/example/src/demos/DataBindingArtboardsExample.tsx index e2748bed..1bd553d2 100644 --- a/example/src/demos/DataBindingArtboardsExample.tsx +++ b/example/src/demos/DataBindingArtboardsExample.tsx @@ -78,7 +78,7 @@ function ArtboardSwapper({ mainFile: RiveFile; assetsFile: RiveFile; }) { - const { instance, error } = useViewModelInstance(mainFile); + const { instance, error } = useViewModelInstance(mainFile, { async: true }); const [currentArtboard, setCurrentArtboard] = useState('Dragon'); const initializedRef = useRef(false); diff --git a/example/src/demos/QuickStart.tsx b/example/src/demos/QuickStart.tsx index 4b658262..5850b5ee 100644 --- a/example/src/demos/QuickStart.tsx +++ b/example/src/demos/QuickStart.tsx @@ -6,7 +6,7 @@ - Data Binding: https://rive.app/docs/runtimes/data-binding */ -import { Button, View, StyleSheet } from 'react-native'; +import { Button, Text, View, StyleSheet } from 'react-native'; import { RiveView, useRive, @@ -19,13 +19,17 @@ import { import type { Metadata } from '../shared/metadata'; export default function QuickStart() { - const { riveFile } = useRiveFile( + const { riveFile, error: fileError } = useRiveFile( require('../../assets/rive/quick_start.riv') ); const { riveViewRef, setHybridRef } = useRive(); - const { instance: viewModelInstance } = useViewModelInstance(riveFile, { - onInit: (vmi) => vmi.numberProperty('health')!.set(9), - }); + const { instance: viewModelInstance, error } = useViewModelInstance( + riveFile, + { + async: true, + onInit: (vmi) => vmi.numberProperty('health')!.set(9), + } + ); const { setValue: setHealth } = useRiveNumber('health', viewModelInstance); @@ -37,20 +41,28 @@ export default function QuickStart() { const handleTakeDamage = () => { setHealth((h) => (h ?? 0) - 7); - riveViewRef!.play(); + riveViewRef?.play(); }; const handleMaxHealth = () => { setHealth(100); - riveViewRef!.play(); + riveViewRef?.play(); }; const handleGameOver = () => { setHealth(0); gameOverTrigger(); - riveViewRef!.play(); + riveViewRef?.play(); }; + if (fileError || error) { + return ( + + {(fileError ?? error)!.message} + + ); + } + return ( {riveFile && viewModelInstance && ( @@ -86,4 +98,7 @@ const styles = StyleSheet.create({ width: '100%', height: 400, }, + errorText: { + color: 'red', + }, }); diff --git a/example/src/exercisers/FontFallbackExample.tsx b/example/src/exercisers/FontFallbackExample.tsx index 931593d6..ced591fc 100644 --- a/example/src/exercisers/FontFallbackExample.tsx +++ b/example/src/exercisers/FontFallbackExample.tsx @@ -258,7 +258,7 @@ function MountedView({ text }: { text: string }) { // https://rive.app/marketplace/26480-49641-simple-test-text-property/ require('../../assets/rive/font_fallback.riv') ); - const { instance } = useViewModelInstance(riveFile); + const { instance } = useViewModelInstance(riveFile, { async: true }); const { setValue: setRiveText, error: textError } = useRiveString( TEXT_PROPERTY, diff --git a/example/src/exercisers/MenuListExample.tsx b/example/src/exercisers/MenuListExample.tsx index d81ce97c..4960210f 100644 --- a/example/src/exercisers/MenuListExample.tsx +++ b/example/src/exercisers/MenuListExample.tsx @@ -41,7 +41,7 @@ export default function MenuListExample() { } function MenuList({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { console.error(error.message); diff --git a/example/src/exercisers/NestedViewModelExample.tsx b/example/src/exercisers/NestedViewModelExample.tsx index fc3f4d24..e66c358e 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -40,7 +40,7 @@ export default function NestedViewModelExample() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { console.error(error.message); diff --git a/example/src/exercisers/RiveDataBindingExample.tsx b/example/src/exercisers/RiveDataBindingExample.tsx index 825c81f7..48713b79 100644 --- a/example/src/exercisers/RiveDataBindingExample.tsx +++ b/example/src/exercisers/RiveDataBindingExample.tsx @@ -37,7 +37,7 @@ export default function WithRiveFile() { } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { console.error(error.message); diff --git a/example/src/reproducers/Issue297ThreadRace.tsx b/example/src/reproducers/Issue297ThreadRace.tsx index 10af8f40..02734dd1 100644 --- a/example/src/reproducers/Issue297ThreadRace.tsx +++ b/example/src/reproducers/Issue297ThreadRace.tsx @@ -130,7 +130,7 @@ function StressRunner({ } function WithViewModelSetup({ file }: { file: RiveFile }) { - const { instance, error } = useViewModelInstance(file); + const { instance, error } = useViewModelInstance(file, { async: true }); if (error) { return {error.message}; diff --git a/ios/HybridRiveFile.swift b/ios/HybridRiveFile.swift index 11b5a5fa..aa1349c5 100644 --- a/ios/HybridRiveFile.swift +++ b/ios/HybridRiveFile.swift @@ -90,8 +90,13 @@ class HybridRiveFile: HybridRiveFileSpec, RiveViewSource { return HybridBindableArtboard(bindableArtboard: bindable) } + // The *Async lookups run on the main thread (not Nitro's pool): they walk + // the same RiveFile the attached views render on the main thread, and the + // legacy runtime has no internal synchronization — off-main access is the + // race class of issue #297. "Async" here means "doesn't block the JS + // thread", matching HybridViewModel's create*InstanceAsync. func getViewModelNamesAsync() throws -> Promise<[String]> { - return Promise.async { + return Promise.onMain { guard let file = self.riveFile else { return [] } let count = file.viewModelCount var names: [String] = [] @@ -105,19 +110,19 @@ class HybridRiveFile: HybridRiveFileSpec, RiveViewSource { } func viewModelByNameAsync(name: String, validate: Bool?) throws -> Promise<(any HybridViewModelSpec)?> { - return Promise.async { try self.viewModelByName(name: name) } + return Promise.onMain { try self.viewModelByName(name: name) } } func defaultArtboardViewModelAsync(artboardBy: ArtboardBy?) throws -> Promise<(any HybridViewModelSpec)?> { - return Promise.async { try self.defaultArtboardViewModel(artboardBy: artboardBy) } + return Promise.onMain { try self.defaultArtboardViewModel(artboardBy: artboardBy) } } func getArtboardCountAsync() throws -> Promise { - return Promise.async { self.artboardCount } + return Promise.onMain { self.artboardCount } } func getArtboardNamesAsync() throws -> Promise<[String]> { - return Promise.async { self.artboardNames } + return Promise.onMain { self.artboardNames } } func updateReferencedAssets(referencedAssets: ReferencedAssetsType) { diff --git a/package.json b/package.json index 0898b690..b2bc6e54 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "copy:nitrogen-config": "mkdir -p lib/nitrogen/generated/shared/json && cp nitrogen/generated/shared/json/RiveViewConfig.json lib/nitrogen/generated/shared/json/", "lint:swift": "./scripts/lint-swift.sh", "lint:kotlin": "./scripts/lint-kotlin.sh", - "lint:native": "yarn lint:swift && yarn lint:kotlin" + "lint:native": "yarn lint:swift && yarn lint:kotlin", + "typetest": "tsd --typings src/index.tsx" }, "keywords": [ "react-native", @@ -100,6 +101,7 @@ "react-native-nitro-modules": "0.35.10", "react-test-renderer": "19.0.0", "release-it": "^17.10.0", + "tsd": "^0.33.0", "turbo": "^1.10.7", "typescript": "^5.2.2" }, @@ -124,6 +126,10 @@ "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" ], + "testPathIgnorePatterns": [ + "/node_modules/", + "\\.test-d\\.ts$" + ], "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" @@ -189,6 +195,24 @@ ] ] }, + "tsd": { + "typings": "src/index.tsx", + "directory": "src/__tests__", + "compilerOptions": { + "paths": { + "@rive-app/react-native": [ + "./src/index" + ] + }, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": [ + "ESNext" + ], + "strict": true, + "allowArbitraryExtensions": true + } + }, "create-react-native-library": { "languages": "kotlin-swift", "type": "nitro-module", diff --git a/src/__tests__/useViewModelInstance.test-d.ts b/src/__tests__/useViewModelInstance.test-d.ts new file mode 100644 index 00000000..9d973688 --- /dev/null +++ b/src/__tests__/useViewModelInstance.test-d.ts @@ -0,0 +1,93 @@ +/** + * tsd pins for useViewModelInstance's overload resolution (`yarn typetest`). + * + * - expectDeprecated: the call must resolve to a @deprecated overload + * (any call not provably `async: true` — bare, async: false, widened + * params, exported param types). + * - expectNotDeprecated: literal `async: true` calls must stay clean. + * - expectError pins invalid param combinations. + * - expectType pins the `required` narrowing behavior. + */ +import { + expectDeprecated, + expectNotDeprecated, + expectError, + expectType, + expectAssignable, +} from 'tsd'; +import { useViewModelInstance } from '../index'; +import type { + UseViewModelInstanceRequiredResult, + UseViewModelInstanceResult, +} from '../index'; +import type { UseViewModelInstanceFileParams } from '../hooks/useViewModelInstance'; +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { ViewModel } from '../specs/ViewModel.nitro'; +import type { RiveViewRef } from '../index'; + +declare const file: RiveFile; +declare const nullableFile: RiveFile | null | undefined; +declare const vm: ViewModel; +declare const ref: RiveViewRef; + +// ── must resolve to a @deprecated overload ─────────────────────────── +expectDeprecated(useViewModelInstance(file)); +expectDeprecated(useViewModelInstance(nullableFile)); +expectDeprecated(useViewModelInstance(file, { instanceName: 'X' })); +expectDeprecated(useViewModelInstance(file, { async: false })); +expectDeprecated(useViewModelInstance(vm, { name: 'X' })); +expectDeprecated(useViewModelInstance(ref)); + +// widened `async: boolean` params resolve to the deprecated overload by +// design — the deprecation message tells callers to re-pin with +// `{ ...params, async: true }` +declare const widened: { async: boolean }; +expectDeprecated(useViewModelInstance(file, widened)); + +// values of the exported param types (async?: boolean) do too +declare const viaExportedType: UseViewModelInstanceFileParams; +expectDeprecated(useViewModelInstance(file, viaExportedType)); + +// ── must stay non-deprecated ───────────────────────────────────────── +expectNotDeprecated(useViewModelInstance(file, { async: true })); +expectNotDeprecated(useViewModelInstance(nullableFile, { async: true })); +expectNotDeprecated( + useViewModelInstance(file, { async: true, artboardName: 'Main' }) +); +expectNotDeprecated( + useViewModelInstance(file, { async: true, viewModelName: 'Settings' }) +); +expectNotDeprecated(useViewModelInstance(vm, { async: true, useNew: true })); +expectNotDeprecated(useViewModelInstance(ref, { async: true })); +// the documented fix for widened params re-pins the literal at the call: +expectNotDeprecated( + useViewModelInstance(file, { ...viaExportedType, async: true }) +); + +// ── result types ───────────────────────────────────────────────────── +// required narrowing needs a literal async AND a non-nullable source +expectType( + useViewModelInstance(file, { async: true, required: true }) +); +// nullable source falls back to the standard result (runtime still throws) +expectType( + useViewModelInstance(nullableFile, { async: true, required: true }) +); +// widened params keep the standard result shape +expectType(useViewModelInstance(file, widened)); +// the required result is a subset of the standard result +expectAssignable( + useViewModelInstance(file, { async: true, required: true }) +); + +// ── invalid combinations must not compile ──────────────────────────── +// artboardName and viewModelName are mutually exclusive +expectError( + useViewModelInstance(file, { + async: true, + artboardName: 'A', + viewModelName: 'B', + }) +); +// file-source params on a ViewModel source +expectError(useViewModelInstance(vm, { async: true, artboardName: 'A' })); diff --git a/src/hooks/__tests__/useRive.test.ts b/src/hooks/__tests__/useRive.test.ts new file mode 100644 index 00000000..8d318caf --- /dev/null +++ b/src/hooks/__tests__/useRive.test.ts @@ -0,0 +1,13 @@ +import { renderHook } from '@testing-library/react-native'; +import { useRive } from '../useRive'; + +describe('useRive', () => { + it('exposes an undefined (pending) riveViewRef before the view is ready, not null', () => { + // `undefined` = pending, `null` = failed — the useRiveFile convention. + // A null here would make useViewModelInstance({async: true})(riveViewRef) settle to + // a terminal "no ViewModel" during normal mount (and throw with + // `required: true`) before the view has even attached. + const { result } = renderHook(() => useRive()); + expect(result.current.riveViewRef).toBeUndefined(); + }); +}); diff --git a/src/hooks/__tests__/useViewModelInstance.async.test.tsx b/src/hooks/__tests__/useViewModelInstance.async.test.tsx new file mode 100644 index 00000000..49ed28ea --- /dev/null +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -0,0 +1,771 @@ +import React from 'react'; +import { + renderHook, + render, + waitFor, + act, +} from '@testing-library/react-native'; +import { + useViewModelInstance, + type UseViewModelInstanceFileParams, +} from '../useViewModelInstance'; +import type { RiveFile } from '../../specs/RiveFile.nitro'; +import type { ViewModel, ViewModelInstance } from '../../specs/ViewModel.nitro'; +import type { ArtboardBy } from '../../specs/ArtboardBy'; +import type { RiveViewRef } from '../../index'; + +function createMockViewModelInstance(name = 'TestInstance'): ViewModelInstance { + return { + instanceName: name, + dispose: jest.fn(), + numberProperty: jest.fn(), + stringProperty: jest.fn(), + booleanProperty: jest.fn(), + colorProperty: jest.fn(), + enumProperty: jest.fn(), + triggerProperty: jest.fn(), + imageProperty: jest.fn(), + listProperty: jest.fn(), + artboardProperty: jest.fn(), + viewModelAsync: jest.fn(), + replaceViewModel: jest.fn(), + } as any; +} + +function createMockViewModel(options?: { + defaultInstance?: ViewModelInstance; + namedInstances?: Record; + blankInstance?: ViewModelInstance; +}): ViewModel { + return { + modelName: 'TestViewModel', + dispose: jest.fn(), + createInstanceByNameAsync: jest.fn( + async (name: string) => options?.namedInstances?.[name] + ), + createDefaultInstanceAsync: jest.fn(async () => options?.defaultInstance), + createBlankInstanceAsync: jest.fn(async () => options?.blankInstance), + getPropertiesAsync: jest.fn(), + getPropertyCountAsync: jest.fn(), + getInstanceCountAsync: jest.fn(), + } as any; +} + +function createMockRiveFile(options?: { + defaultViewModel?: ViewModel; + artboardViewModels?: Record; + namedViewModels?: Record; +}): RiveFile { + return { + dispose: jest.fn(), + getBindableArtboard: jest.fn(), + viewModelByNameAsync: jest.fn( + async (name: string) => options?.namedViewModels?.[name] + ), + defaultArtboardViewModelAsync: jest.fn(async (artboardBy?: ArtboardBy) => { + if (artboardBy?.name && options?.artboardViewModels) { + return options.artboardViewModels[artboardBy.name]; + } + return options?.defaultViewModel; + }), + } as any; +} + +describe('useViewModelInstance async - RiveFile source', () => { + it('is loading on first render, then resolves the default instance', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { async: true }) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.instance).toBeUndefined(); + expect(result.current.error).toBeNull(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.instance).toBe(defaultInstance); + expect(result.current.error).toBeNull(); + expect(defaultViewModel.createDefaultInstanceAsync).toHaveBeenCalled(); + }); + + it('resolves a named instance via createInstanceByNameAsync', async () => { + const personInstance = createMockViewModelInstance('Person'); + const defaultViewModel = createMockViewModel({ + namedInstances: { PersonInstance: personInstance }, + }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'PersonInstance', + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(personInstance)); + expect(defaultViewModel.createInstanceByNameAsync).toHaveBeenCalledWith( + 'PersonInstance' + ); + expect(result.current.error).toBeNull(); + }); + + it('resolves the ViewModel for a specific artboard', async () => { + const mainInstance = createMockViewModelInstance('Main'); + const mainArtboardViewModel = createMockViewModel({ + defaultInstance: mainInstance, + }); + const mockRiveFile = createMockRiveFile({ + artboardViewModels: { MainArtboard: mainArtboardViewModel }, + }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + artboardName: 'MainArtboard', + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(mainInstance)); + expect(mockRiveFile.defaultArtboardViewModelAsync).toHaveBeenCalledWith({ + type: 'name', + name: 'MainArtboard', + }); + }); + + it('resolves a ViewModel by name', async () => { + const settingsInstance = createMockViewModelInstance('Settings'); + const settingsViewModel = createMockViewModel({ + defaultInstance: settingsInstance, + }); + const mockRiveFile = createMockRiveFile({ + namedViewModels: { Settings: settingsViewModel }, + }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + viewModelName: 'Settings', + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(settingsInstance)); + expect(mockRiveFile.viewModelByNameAsync).toHaveBeenCalledWith('Settings'); + expect(mockRiveFile.defaultArtboardViewModelAsync).not.toHaveBeenCalled(); + }); + + it('sets an error when the instance name is not found (not required)', async () => { + const defaultViewModel = createMockViewModel({ namedInstances: {} }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'NonExistent', + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toContain('NonExistent'); + }); + + it('attaches the native cause when createInstanceByNameAsync rejects', async () => { + // A rejection is a real creation failure — keep the clean "not found" + // message but preserve the native diagnostic as `error.cause`. + const nativeError = new Error( + 'invalidViewModelInstance("Could not create ...")' + ); + const defaultViewModel = createMockViewModel({}); + (defaultViewModel.createInstanceByNameAsync as jest.Mock).mockRejectedValue( + nativeError + ); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'Whatever', + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toBe( + "ViewModel instance 'Whatever' not found" + ); + expect(result.current.error?.cause).toBe(nativeError); + }); + + it('has no cause when a missing instance resolves to null (not a rejection)', async () => { + const defaultViewModel = createMockViewModel({ namedInstances: {} }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + instanceName: 'Missing', + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error?.message).toBe( + "ViewModel instance 'Missing' not found" + ); + expect(result.current.error?.cause).toBeUndefined(); + }); + + it('resolves null with no error when the artboard has no ViewModel', async () => { + const mockRiveFile = createMockRiveFile({}); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { async: true }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('maps an artboard-lookup rejection to the not-found error', async () => { + // The experimental backend (feat/rive-ios-experimental) *throws* on an + // unknown artboard name while this backend resolves undefined; a rejection + // must map to the same friendly message the resolve-undefined path + // produces. + const mockRiveFile = createMockRiveFile({}); + (mockRiveFile.defaultArtboardViewModelAsync as jest.Mock).mockRejectedValue( + new Error('Artboard not found in file') + ); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + artboardName: 'NonExistent', + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe( + "Artboard 'NonExistent' not found or has no ViewModel" + ); + // The native diagnostic must survive as `cause`, not be swallowed. + expect((result.current.error as Error & { cause?: unknown }).cause).toEqual( + new Error('Artboard not found in file') + ); + }); + + it('artboard resolve-undefined miss carries no cause', async () => { + const mockRiveFile = createMockRiveFile({}); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + artboardName: 'NonExistent', + }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.error?.message).toBe( + "Artboard 'NonExistent' not found or has no ViewModel" + ); + expect( + (result.current.error as Error & { cause?: unknown }).cause + ).toBeUndefined(); + }); + + it('preserves the value of a non-Error rejection in the error message', async () => { + const defaultViewModel = createMockViewModel(); + ( + defaultViewModel.createDefaultInstanceAsync as jest.Mock + ).mockRejectedValue('file was disposed'); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { async: true }) + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe('file was disposed'); + }); + + it('propagates a rejection unchanged when no artboardName was given', async () => { + const mockRiveFile = createMockRiveFile({}); + (mockRiveFile.defaultArtboardViewModelAsync as jest.Mock).mockRejectedValue( + new Error('native boom') + ); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { async: true }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error?.message).toBe('native boom'); + }); +}); + +describe('useViewModelInstance async - ViewModel source', () => { + it('uses createDefaultInstanceAsync by default', async () => { + const defaultInstance = createMockViewModelInstance(); + const mockViewModel = createMockViewModel({ defaultInstance }); + + const { result } = renderHook(() => + useViewModelInstance(mockViewModel, { async: true }) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + expect(mockViewModel.createDefaultInstanceAsync).toHaveBeenCalled(); + }); + + it('uses createBlankInstanceAsync when useNew is true', async () => { + const blankInstance = createMockViewModelInstance('Blank'); + const mockViewModel = createMockViewModel({ blankInstance }); + + const { result } = renderHook(() => + useViewModelInstance(mockViewModel, { + async: true, + useNew: true, + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(blankInstance)); + expect(mockViewModel.createBlankInstanceAsync).toHaveBeenCalled(); + expect(mockViewModel.createDefaultInstanceAsync).not.toHaveBeenCalled(); + }); + + it('uses createInstanceByNameAsync when name is provided', async () => { + const namedInstance = createMockViewModelInstance('Gordon'); + const mockViewModel = createMockViewModel({ + namedInstances: { Gordon: namedInstance }, + }); + + const { result } = renderHook(() => + useViewModelInstance(mockViewModel, { + async: true, + name: 'Gordon', + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(namedInstance)); + expect(mockViewModel.createInstanceByNameAsync).toHaveBeenCalledWith( + 'Gordon' + ); + }); +}); + +function createMockRiveViewRef( + getViewModelInstance: () => ViewModelInstance | null | undefined +): RiveViewRef { + return { getViewModelInstance: jest.fn(getViewModelInstance) } as any; +} + +describe('useViewModelInstance - param type compatibility', () => { + // These pin overload resolution as much as runtime behavior: a params + // object built separately widens `async` to boolean, and the exported + // params types declare `async?: boolean` — both must keep COMPILING and + // behave per the runtime value (`yarn tsc` is the gate). By design they + // resolve to the deprecated overloads: the warning's message tells such + // callers to re-pin with `{ ...params, async: true }`. + it('accepts a params object with a widened boolean async', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const params = { async: true }; // widens to { async: boolean } + const { result } = renderHook(() => + // eslint-disable-next-line @typescript-eslint/no-deprecated -- widened async resolves to the deprecated overload by design; runtime still honors the value + useViewModelInstance(mockRiveFile, params) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + }); + + it('accepts a value of the exported file-params type with a nullable source', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile: RiveFile | null | undefined = createMockRiveFile({ + defaultViewModel, + }); + + const params: UseViewModelInstanceFileParams = { async: true }; + const { result } = renderHook(() => + // eslint-disable-next-line @typescript-eslint/no-deprecated -- exported param types carry async?: boolean and resolve to the deprecated overload by design + useViewModelInstance(mockRiveFile, params) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + }); +}); + +describe('useViewModelInstance async - RiveViewRef source', () => { + it('resolves the view-bound instance', async () => { + const vmi = createMockViewModelInstance(); + const ref = createMockRiveViewRef(() => vmi); + + const { result } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); + + await waitFor(() => expect(result.current.instance).toBe(vmi)); + expect(result.current.error).toBeNull(); + }); + + it('does not dispose a view-owned instance on unmount', async () => { + const vmi = createMockViewModelInstance(); + const ref = createMockRiveViewRef(() => vmi); + + const { result, unmount } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); + + await waitFor(() => expect(result.current.instance).toBe(vmi)); + unmount(); + expect(vmi.dispose).not.toHaveBeenCalled(); + }); + + it('retries until the auto-bound instance appears', async () => { + // Auto-bind completes a short time after the ref is assigned; the hook + // must poll rather than settle a terminal null on the first read. + const vmi = createMockViewModelInstance(); + let calls = 0; + const ref = createMockRiveViewRef(() => (++calls >= 3 ? vmi : undefined)); + + const { result } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); + + expect(result.current.isLoading).toBe(true); + await waitFor(() => expect(result.current.instance).toBe(vmi), { + timeout: 2000, + }); + expect(calls).toBeGreaterThanOrEqual(3); + }); + + it('stops polling when unmounted before the instance appears', async () => { + const getVmi = jest.fn((): ViewModelInstance | undefined => undefined); + const ref = { getViewModelInstance: getVmi } as unknown as RiveViewRef; + + const { unmount } = renderHook(() => + useViewModelInstance(ref, { async: true }) + ); + unmount(); + + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + }); + const callsAfterUnmount = getVmi.mock.calls.length; + await act(async () => { + await new Promise((r) => setTimeout(r, 200)); + }); + expect(getVmi.mock.calls.length).toBe(callsAfterUnmount); + }); +}); + +describe('useViewModelInstance async - null vs undefined source', () => { + it('settles to a terminal null when the source is null (e.g. file load failed)', async () => { + const { result } = renderHook(() => + useViewModelInstance(null, { async: true }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.instance).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('stays in the loading state when the source is undefined (still resolving)', async () => { + const { result } = renderHook(() => + useViewModelInstance(undefined, { async: true }) + ); + + expect(result.current.isLoading).toBe(true); + expect(result.current.instance).toBeUndefined(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.instance).toBeUndefined(); + }); +}); + +describe('useViewModelInstance async - onInit', () => { + it('calls onInit with the resolved instance', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + const onInit = jest.fn(); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + onInit, + }) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + expect(onInit).toHaveBeenCalledWith(defaultInstance); + expect(onInit).toHaveBeenCalledTimes(1); + }); + + it('surfaces an onInit throw as the error result', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result } = renderHook(() => + useViewModelInstance(mockRiveFile, { + async: true, + onInit: () => { + throw new Error('init boom'); + }, + }) + ); + + await waitFor(() => expect(result.current.error).toBeInstanceOf(Error)); + expect(result.current.error?.message).toBe('init boom'); + expect(result.current.instance).toBeNull(); + expect(defaultInstance.dispose).toHaveBeenCalled(); + }); +}); + +describe('useViewModelInstance async - disposal', () => { + it('disposes the instance on unmount', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel({ defaultInstance }); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { result, unmount } = renderHook(() => + useViewModelInstance(mockRiveFile, { async: true }) + ); + + await waitFor(() => expect(result.current.instance).toBe(defaultInstance)); + unmount(); + expect(defaultInstance.dispose).toHaveBeenCalled(); + }); + + it('disposes a late-resolving instance when unmounted before resolution', async () => { + const defaultInstance = createMockViewModelInstance(); + const defaultViewModel = createMockViewModel(); + let resolveCreate: (v: ViewModelInstance) => void = () => {}; + (defaultViewModel.createDefaultInstanceAsync as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveCreate = resolve; + }) + ); + const mockRiveFile = createMockRiveFile({ defaultViewModel }); + + const { unmount } = renderHook(() => + useViewModelInstance(mockRiveFile, { async: true }) + ); + + unmount(); + + await act(async () => { + resolveCreate(defaultInstance); + await Promise.resolve(); + }); + + expect(defaultInstance.dispose).toHaveBeenCalled(); + }); + + it('never commits a frame pairing the new source with the old instance', async () => { + // On source change the reset must happen during render, not in the + // effect: otherwise React commits one frame of + // (isLoading false, so + // consumer guards can't catch it) and then disposes instanceA while it + // is still the committed dataBind. + const instanceA = createMockViewModelInstance('A'); + const fileA = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceA }), + }); + const instanceB = createMockViewModelInstance('B'); + const fileB = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceB }), + }); + + const frames: Array<{ + file: RiveFile; + instance: ViewModelInstance | null | undefined; + isLoading: boolean; + }> = []; + function Probe({ file }: { file: RiveFile }) { + const { instance, isLoading } = useViewModelInstance(file, { + async: true, + }); + React.useEffect(() => { + frames.push({ file, instance, isLoading }); + }); + return null; + } + + const view = render(); + await waitFor(() => + expect(frames.some((f) => f.instance === instanceA)).toBe(true) + ); + + await act(async () => { + view.rerender(); + await Promise.resolve(); + }); + await waitFor(() => + expect(frames.some((f) => f.instance === instanceB)).toBe(true) + ); + + const mismatched = frames.filter( + (f) => f.file === fileB && f.instance === instanceA + ); + expect(mismatched).toEqual([]); + }); + + it('disposes the previous instance when the source changes', async () => { + const instanceA = createMockViewModelInstance('A'); + const fileA = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceA }), + }); + const instanceB = createMockViewModelInstance('B'); + const fileB = createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance: instanceB }), + }); + + const { result, rerender } = renderHook( + ({ file }: { file: RiveFile }) => + useViewModelInstance(file, { async: true }), + { initialProps: { file: fileA } } + ); + + await waitFor(() => expect(result.current.instance).toBe(instanceA)); + + await act(async () => { + rerender({ file: fileB }); + await Promise.resolve(); + }); + + await waitFor(() => expect(result.current.instance).toBe(instanceB)); + expect(instanceA.dispose).toHaveBeenCalled(); + }); +}); + +describe('useViewModelInstance async - required', () => { + class ErrorBoundary extends React.Component< + { onError: (e: Error) => void; children?: React.ReactNode }, + { hasError: boolean } + > { + state = { hasError: false }; + static getDerivedStateFromError() { + return { hasError: true }; + } + componentDidCatch(error: Error) { + this.props.onError(error); + } + render() { + return this.state.hasError ? null : this.props.children; + } + } + + function Probe({ source }: { source: RiveFile }) { + useViewModelInstance(source, { + async: true, + viewModelName: 'NonExistent', + required: true, + }); + return null; + } + + it('throws (caught by an Error Boundary) once it resolves to null', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const mockRiveFile = createMockRiveFile({ namedViewModels: {} }); + const onError = jest.fn(); + + render( + + + + ); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const message = (onError.mock.calls[0][0] as Error).message; + expect(message).toContain('NonExistent'); + // Users called useViewModelInstance — the internal hook name must not leak. + expect(message).toContain('useViewModelInstance:'); + expect(message).not.toContain('useViewModelInstanceAsync'); + consoleError.mockRestore(); + }); + + it('names the absent source (not the ViewModel) when required throws for a null source', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const onError = jest.fn(); + + function NullSourceProbe() { + useViewModelInstance(null as RiveFile | null, { + async: true, + required: true, + }); + return null; + } + + render( + + + + ); + + await waitFor(() => expect(onError).toHaveBeenCalled()); + const message = (onError.mock.calls[0][0] as Error).message; + // A null source means the file/view failed upstream — pointing users at + // "Ensure the source has a valid ViewModel" sends them the wrong way. + expect(message).toContain('useViewModelInstance:'); + expect(message).toMatch(/source is null/i); + expect(message).toMatch(/useRiveFile|useRive/); + consoleError.mockRestore(); + }); +}); + +describe('useViewModelInstance - async flag constancy guard', () => { + it('reports an actionable error when async changes between renders', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const defaultInstance = createMockViewModelInstance(); + // The initial render takes the sync path, so the mock must speak the + // sync API too (the shared factories only stub the *Async surface). + const mockRiveFile = { + ...createMockRiveFile({ + defaultViewModel: createMockViewModel({ defaultInstance }), + }), + defaultArtboardViewModel: jest.fn(() => ({ + dispose: jest.fn(), + createDefaultInstance: jest.fn(() => defaultInstance), + createInstanceByName: jest.fn(), + createInstance: jest.fn(), + })), + } as unknown as RiveFile; + + const { rerender } = renderHook( + ({ isAsync }: { isAsync: boolean }) => + // eslint-disable-next-line @typescript-eslint/no-deprecated -- dynamic async is the misuse under test + useViewModelInstance(mockRiveFile, { async: isAsync }), + { initialProps: { isAsync: false } } + ); + + // Flipping the flag switches hook implementations — React will throw its + // generic hooks-order invariant; the hook must first explain why. + expect(() => rerender({ isAsync: true })).toThrow(); + expect( + consoleError.mock.calls.some((c) => + String(c[0]).includes('`async` param changed between renders') + ) + ).toBe(true); + consoleError.mockRestore(); + }); +}); diff --git a/src/hooks/__tests__/useViewModelInstance.test.ts b/src/hooks/__tests__/useViewModelInstance.test.ts index 6b8680e3..b8f3aa55 100644 --- a/src/hooks/__tests__/useViewModelInstance.test.ts +++ b/src/hooks/__tests__/useViewModelInstance.test.ts @@ -363,11 +363,22 @@ describe('useViewModelInstance - ViewModel source', () => { }); }); -describe('useViewModelInstance - null source', () => { - it('should return undefined instance when source is null', () => { +describe('useViewModelInstance - null vs undefined source', () => { + it('settles to a terminal null when the source is null (e.g. file load failed)', () => { + // Mirrors the async path: `useRiveFile` returns null on error and + // undefined while loading — a null source must not read as isLoading. const { result } = renderHook(() => useViewModelInstance(null)); + expect(result.current.instance).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('reports isLoading while the source is undefined (still resolving)', () => { + const { result } = renderHook(() => useViewModelInstance(undefined)); + expect(result.current.instance).toBeUndefined(); + expect(result.current.isLoading).toBe(true); expect(result.current.error).toBeNull(); }); }); diff --git a/src/hooks/useRive.ts b/src/hooks/useRive.ts index 078a77ee..fd3cb559 100644 --- a/src/hooks/useRive.ts +++ b/src/hooks/useRive.ts @@ -3,7 +3,13 @@ import type { RiveViewRef } from '@rive-app/react-native'; export function useRive() { const riveRef = useRef(null); - const [riveViewRef, setRiveViewRef] = useState(null); + // `undefined` = view not ready yet, `null` = failed/detached — the same + // convention as useRiveFile, so hooks consuming the ref (e.g. + // useViewModelInstance({async: true})) stay in their loading state until the view is + // actually ready instead of settling on a transient null. + const [riveViewRef, setRiveViewRef] = useState< + RiveViewRef | null | undefined + >(undefined); const timeoutRef = useRef | null>(null); const setRef = useCallback((node: RiveViewRef | null) => { diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index f5acea77..20003553 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -7,8 +7,18 @@ import type { RiveViewRef } from '../index'; import { callDispose } from '../core/callDispose'; import { ArtboardByName } from '../specs/ArtboardBy'; import { useDisposableMemo } from './useDisposableMemo'; +import { useViewModelInstanceAsync } from './useViewModelInstanceAsync'; interface UseViewModelInstanceBaseParams { + /** + * Create the instance via the async runtime APIs (off the JS thread). + * While creation is in flight the hook reports `isLoading: true` with an + * `undefined` instance. Will become the default in the next major. + * + * Must stay constant for the lifetime of the component — it selects + * between two hook implementations. + */ + async?: boolean; /** * If true, throws an error when the instance cannot be obtained. * This is useful with Error Boundaries and ensures TypeScript knows @@ -16,8 +26,9 @@ interface UseViewModelInstanceBaseParams { */ required?: boolean; /** - * Called synchronously when a new instance is created, before the hook returns. - * Use this to set initial values that need to be available immediately. + * Called when a new instance is created, before the hook exposes it — + * synchronously during render without `async: true`, or right after the + * instance resolves (before it is published) with `async: true`. * Note: This callback is excluded from deps - changing it won't recreate the instance. */ onInit?: (instance: ViewModelInstance) => void; @@ -144,9 +155,16 @@ function createInstance( return { instance: null, needsDispose: false }; } } - const vmi = instanceName - ? viewModel.createInstanceByName(instanceName) - : viewModel.createDefaultInstance(); + let vmi: ViewModelInstance | undefined; + if (instanceName) { + try { + vmi = viewModel.createInstanceByName(instanceName); + } catch (e) { + console.warn(`createInstanceByName('${instanceName}') failed:`, e); + } + } else { + vmi = viewModel.createDefaultInstance(); + } if (!vmi && instanceName) { return { instance: null, @@ -160,7 +178,11 @@ function createInstance( // ViewModel source let vmi: ViewModelInstance | undefined; if (instanceName) { - vmi = source.createInstanceByName(instanceName); + try { + vmi = source.createInstanceByName(instanceName); + } catch (e) { + console.warn(`createInstanceByName('${instanceName}') failed:`, e); + } if (!vmi) { return { instance: null, @@ -177,123 +199,166 @@ function createInstance( } export type UseViewModelInstanceResult = - | { instance: ViewModelInstance; error: null } - | { instance: null; error: Error } - | { instance: null; error: null } - | { instance: undefined; error: null }; + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: null; isLoading: false; error: Error } + | { instance: null; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; + +/** + * Result of {@link useViewModelInstance} when `required: true` is set. + * The `null` (error/absent) case is removed — instead the hook throws once the + * instance resolves to `null`, leaving only the ready and loading states. + */ +export type UseViewModelInstanceRequiredResult = + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; /** * Hook for getting a ViewModelInstance from a RiveFile, ViewModel, or RiveViewRef. * + * Pass `async: true` to create the instance via the async runtime APIs, + * resolving off the JS thread. The instance is then not available on the + * first render — guard on the result: + * + * ```tsx + * const { riveFile, error: fileError } = useRiveFile(require('./animation.riv')); + * const { instance, isLoading, error } = useViewModelInstance(riveFile, { async: true }); + * if (fileError || error) return ; + * if (isLoading || !instance) return ; + * // ... + * + * ``` + * + * Without `async: true` (deprecated) the instance is created synchronously + * during render via deprecated runtime APIs that block the JS thread. The + * async path will become the default in the next major. + * + * In both modes, a `null` source settles to a terminal + * `{ instance: null, isLoading: false }` while an `undefined` source keeps + * the hook loading. This mirrors {@link useRiveFile} (`riveFile: undefined` + * while loading, `null` on error) and `useRive` (`riveViewRef: undefined` + * until the view is ready, `null` on failure) — so when chaining, check the + * upstream hook's own `error`, since this hook cannot observe why the source + * is absent. + * * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from * @param params - Configuration for which instance to retrieve - * @returns An object with `instance` and `error` (discriminated union) + * @returns An object with `instance`, `isLoading`, and `error` (discriminated union) * * @example * ```tsx * // From RiveFile (get default instance) * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile); + * const { instance, isLoading } = useViewModelInstance(riveFile, { async: true }); * ``` * * @example * ```tsx * // From RiveFile with specific instance name - * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile, { instanceName: 'PersonInstance' }); + * const { instance } = useViewModelInstance(riveFile, { async: true, instanceName: 'PersonInstance' }); * ``` * * @example * ```tsx * // From RiveFile with specific ViewModel name - * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile, { viewModelName: 'Settings' }); + * const { instance } = useViewModelInstance(riveFile, { async: true, viewModelName: 'Settings' }); * ``` * * @example * ```tsx * // From RiveFile with specific artboard - * const { riveFile } = useRiveFile(require('./animation.riv')); - * const { instance } = useViewModelInstance(riveFile, { artboardName: 'MainArtboard' }); + * const { instance } = useViewModelInstance(riveFile, { async: true, artboardName: 'MainArtboard' }); * ``` * * @example * ```tsx - * // From RiveViewRef (get auto-bound instance) + * // From RiveViewRef (waits for the view's auto-bound instance) * const { riveViewRef, setHybridRef } = useRive(); - * const { instance } = useViewModelInstance(riveViewRef); - * ``` - * - * @example - * ```tsx - * // From ViewModel - * const viewModel = file.viewModelByName('main'); - * const { instance } = useViewModelInstance(viewModel); + * const { instance } = useViewModelInstance(riveViewRef, { async: true }); * ``` * * @example * ```tsx * // Create a new blank instance from ViewModel - * const viewModel = file.viewModelByName('TodoItem'); - * const { instance } = useViewModelInstance(viewModel, { useNew: true }); + * const { instance } = useViewModelInstance(viewModel, { async: true, useNew: true }); * ``` * * @example * ```tsx - * // With required: true (throws if null, use with Error Boundary) - * const { instance } = useViewModelInstance(riveFile, { required: true }); - * // instance is guaranteed to be non-null here + * // With required: true (throws once resolved to null, use with Error Boundary). + * // Note: instance is still `undefined` while loading — guard on isLoading. + * // The required-narrowed return type applies only when the source's TYPE is + * // non-nullable; with a nullable source (e.g. straight from useRiveFile) the + * // call resolves to the standard overload — the runtime throw still applies. + * const { instance, isLoading } = useViewModelInstance(riveFile, { async: true, required: true }); * ``` * * @example * ```tsx - * // With onInit to set initial values synchronously + * // With onInit to set initial values before the instance is exposed or bound * const { instance } = useViewModelInstance(riveFile, { + * async: true, * onInit: (vmi) => { - * vmi.numberProperty('count').set(initialCount); - * vmi.stringProperty('name').set(userName); + * vmi.numberProperty('count')?.set(initialCount); * } * }); * ``` - * - * @example - * ```tsx - * // Error handling - * const { instance, error } = useViewModelInstance(riveFile, { viewModelName: 'Missing' }); - * if (error) console.error(error.message); - * ``` */ // 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; +/** @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 } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; +): UseViewModelInstanceRequiredResult; +/** @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; // ViewModel overloads +export function useViewModelInstance( + source: ViewModel, + params: UseViewModelInstanceViewModelParams & { async: true; required: true } +): UseViewModelInstanceRequiredResult; +export function useViewModelInstance( + source: ViewModel | null | undefined, + params: UseViewModelInstanceViewModelParams & { async: true } +): UseViewModelInstanceResult; +/** @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: ViewModel, params: UseViewModelInstanceViewModelParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; +): UseViewModelInstanceRequiredResult; +/** @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: ViewModel | null | undefined, params?: UseViewModelInstanceViewModelParams ): UseViewModelInstanceResult; // RiveViewRef overloads +export function useViewModelInstance( + source: RiveViewRef, + params: UseViewModelInstanceRefParams & { async: true; required: true } +): UseViewModelInstanceRequiredResult; +export function useViewModelInstance( + source: RiveViewRef | null | undefined, + params: UseViewModelInstanceRefParams & { async: true } +): UseViewModelInstanceResult; +/** @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: RiveViewRef, params: UseViewModelInstanceRefParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; +): UseViewModelInstanceRequiredResult; +/** @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: RiveViewRef | null | undefined, params?: UseViewModelInstanceRefParams @@ -306,6 +371,39 @@ export function useViewModelInstance( | UseViewModelInstanceFileParams | UseViewModelInstanceViewModelParams | UseViewModelInstanceRefParams +): UseViewModelInstanceResult { + const isAsync = params?.async ?? false; + // The flag selects between two hook implementations (different hook + // orders), so it must stay constant for the lifetime of the component — + // documented on the param. React's own failure for a flip is the cryptic + // "Rendered more hooks than during the previous render"; explain first. + const initialAsyncRef = useRef(isAsync); + if (initialAsyncRef.current !== isAsync) { + console.error( + 'useViewModelInstance: the `async` param changed between renders ' + + `(${String(initialAsyncRef.current)} → ${String(isAsync)}). It selects ` + + 'between two hook implementations, so it must stay constant for the ' + + 'lifetime of the component; remount (e.g. change `key`) to switch modes.' + ); + initialAsyncRef.current = isAsync; + } + if (isAsync) { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useViewModelInstanceAsync( + source as RiveFile | null | undefined, + params as UseViewModelInstanceFileParams + ); + } + // eslint-disable-next-line react-hooks/rules-of-hooks + return useViewModelInstanceSync(source, params); +} + +function useViewModelInstanceSync( + source: ViewModelSource | null | undefined, + params?: + | UseViewModelInstanceFileParams + | UseViewModelInstanceViewModelParams + | UseViewModelInstanceRefParams ): UseViewModelInstanceResult { const fileInstanceName = (params as { instanceName?: string } | undefined) ?.instanceName; @@ -351,6 +449,20 @@ export function useViewModelInstance( [result.error] ); + if (result.instance === undefined && source === null) { + // Source resolved to absent/failed rather than pending (`useRiveFile` + // returns null on load error, undefined while loading) — settle to a + // terminal null instead of reporting isLoading forever, mirroring the + // async path. + if (required) { + throw new Error( + 'useViewModelInstance: source is null — the file or view failed to ' + + "resolve upstream (if it comes from useRiveFile or useRive, check that hook's error)." + ); + } + return { instance: null, isLoading: false, error: null }; + } + if (required && result.instance === null) { throw new Error( result.error @@ -361,10 +473,11 @@ export function useViewModelInstance( } if (result.instance) { - return { instance: result.instance, error: null }; + return { instance: result.instance, isLoading: false, error: null }; } if (result.instance === undefined) { - return { instance: undefined, error: null }; + // Source not resolved yet (e.g. the file is still loading). + return { instance: undefined, isLoading: true, error: null }; } - return { instance: null, error }; + return { instance: null, isLoading: false, error }; } diff --git a/src/hooks/useViewModelInstanceAsync.ts b/src/hooks/useViewModelInstanceAsync.ts new file mode 100644 index 00000000..6304cc78 --- /dev/null +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -0,0 +1,446 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ViewModel, ViewModelInstance } from '../specs/ViewModel.nitro'; +import type { RiveFile } from '../specs/RiveFile.nitro'; +import type { RiveViewRef } from '../index'; +import { callDispose } from '../core/callDispose'; +import { ArtboardByName } from '../specs/ArtboardBy'; +import type { + UseViewModelInstanceFileParams, + UseViewModelInstanceViewModelParams, + UseViewModelInstanceRefParams, +} from './useViewModelInstance'; + +type ViewModelSource = ViewModel | RiveFile | RiveViewRef; + +function isRiveViewRef( + source: ViewModelSource | null | undefined +): source is RiveViewRef { + return source != null && 'getViewModelInstance' in source; +} + +function isRiveFile( + source: ViewModelSource | null | undefined +): source is RiveFile { + return source != null && 'defaultArtboardViewModelAsync' in source; +} + +type CreateInstanceResult = { + instance: ViewModelInstance | null | undefined; + needsDispose: boolean; + error?: Error; +}; + +// The message stays clean and stable ("… not found"); when the native call +// *rejected* the runtime error is attached as `cause` so its diagnostic is +// preserved without leaking into the message. This backend resolves null on +// a bad name (Android also pre-checks), so a missing `cause` is normal; the +// rejection handling exists because the experimental backend +// (feat/rive-ios-experimental) throws instead — the hook keeps one contract +// for both. See #305. +function instanceNotFoundError(instanceName: string, cause?: unknown): Error { + return new Error( + `ViewModel instance '${instanceName}' not found`, + cause !== undefined ? { cause } : undefined + ); +} + +// The view's auto-bound instance resolves asynchronously a short time after +// the ref is assigned and there is no native bind-complete signal to await +// yet, so a one-shot getViewModelInstance() read would settle a terminal null +// on fast mounts. Poll briefly instead; a view with no data binding resolves +// null after the timeout (late, but the correct terminal state). +const REF_BIND_POLL_MS = 50; +const REF_BIND_TIMEOUT_MS = 5000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function createInstanceAsync( + source: ViewModelSource | null | undefined, + instanceName: string | undefined, + artboardName: string | undefined, + viewModelName: string | undefined, + useNew: boolean, + isCancelled: () => boolean +): Promise { + if (!source) { + return { instance: undefined, needsDispose: false }; + } + + if (isRiveViewRef(source)) { + let vmi = source.getViewModelInstance(); + const deadline = Date.now() + REF_BIND_TIMEOUT_MS; + while (!vmi && Date.now() < deadline && !isCancelled()) { + await sleep(REF_BIND_POLL_MS); + vmi = source.getViewModelInstance(); + } + return { instance: vmi ?? null, needsDispose: false }; + } + + if (isRiveFile(source)) { + let viewModel: ViewModel | undefined; + if (viewModelName) { + viewModel = await source.viewModelByNameAsync(viewModelName); + if (!viewModel) { + return { + instance: null, + needsDispose: false, + error: new Error(`ViewModel '${viewModelName}' not found`), + }; + } + } else { + let artboardCause: unknown; + try { + viewModel = await source.defaultArtboardViewModelAsync( + artboardName ? ArtboardByName(artboardName) : undefined + ); + } catch (e) { + // This backend resolves undefined on an unknown artboard name, but + // the experimental backend throws (iOS `createArtboard`, Android + // `Artboard.fromFile`) — map a rejection to the same not-found error + // below, preserving the native diagnostic as `cause`. Without a name + // a rejection is a real error. + if (!artboardName) throw e; + artboardCause = e; + viewModel = undefined; + } + if (!viewModel) { + if (artboardName) { + return { + instance: null, + needsDispose: false, + error: new Error( + `Artboard '${artboardName}' not found or has no ViewModel`, + artboardCause !== undefined ? { cause: artboardCause } : undefined + ), + }; + } + return { instance: null, needsDispose: false }; + } + } + try { + let vmi: ViewModelInstance | undefined; + if (instanceName) { + try { + vmi = await viewModel.createInstanceByNameAsync(instanceName); + } catch (e) { + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName, e), + }; + } + } else { + vmi = await viewModel.createDefaultInstanceAsync(); + } + if (!vmi && instanceName) { + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName), + }; + } + return { instance: vmi ?? null, needsDispose: true }; + } finally { + // The intermediate ViewModel wrapper is hook-internal; disposing it + // releases the native resources it owns (e.g. the artboard resolved + // for DefaultForArtboard sources on the experimental backend). + callDispose(viewModel); + } + } + + // ViewModel source (caller-owned — not disposed here) + let vmi: ViewModelInstance | undefined; + if (instanceName) { + try { + vmi = await source.createInstanceByNameAsync(instanceName); + } catch (e) { + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName, e), + }; + } + if (!vmi) { + return { + instance: null, + needsDispose: false, + error: instanceNotFoundError(instanceName), + }; + } + } else if (useNew) { + vmi = await source.createBlankInstanceAsync(); + } else { + vmi = await source.createDefaultInstanceAsync(); + } + return { instance: vmi ?? null, needsDispose: true }; +} + +export type UseViewModelInstanceAsyncResult = + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: null; isLoading: false; error: Error } + | { instance: null; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; + +/** + * Result of {@link useViewModelInstanceAsync} when `required: true` is set. + * The `null` (error/absent) case is removed — instead the hook throws once the + * instance resolves to `null`, leaving only the ready and loading states. + */ +type UseViewModelInstanceAsyncRequiredResult = + | { instance: ViewModelInstance; isLoading: false; error: null } + | { instance: undefined; isLoading: true; error: null }; + +const LOADING_RESULT: UseViewModelInstanceAsyncResult = { + instance: undefined, + isLoading: true, + error: null, +}; + +/** + * Implementation behind `useViewModelInstance(source, { async: true })` — not + * exported publicly. Creates a ViewModelInstance using the non-deprecated + * `*Async` runtime APIs, resolving off the JS thread. + * + * Because creation is asynchronous, the instance is not available on the first + * render. Consumers should guard on the result: + * + * ```tsx + * const { instance, isLoading, error } = useViewModelInstanceAsync(riveFile); + * if (isLoading || !instance) return ; + * // ... + * + * ``` + * + * A `null` source resolves to a terminal `{ instance: null, isLoading: false }` + * (not perpetual loading), while an `undefined` source keeps the hook loading. + * This mirrors {@link useRiveFile} (`riveFile: undefined` while loading, + * `null` on error) and `useRive` (`riveViewRef: undefined` until the view is + * ready, `null` on failure) — so when chaining, check the upstream hook's own + * `error`, since this hook cannot observe why the source is absent: + * + * ```tsx + * const { riveFile, error: fileError } = useRiveFile(source); + * const { instance, isLoading } = useViewModelInstanceAsync(riveFile); + * if (fileError) return {fileError.message}; + * if (isLoading || !instance) return ; + * ``` + * + * @param source - The RiveFile, ViewModel, or RiveViewRef to get an instance from + * @param params - Configuration for which instance to retrieve + * @returns An object with `instance`, `isLoading`, and `error` (discriminated union) + * + * @example + * ```tsx + * // From RiveFile (get default instance) + * const { riveFile } = useRiveFile(require('./animation.riv')); + * const { instance, isLoading } = useViewModelInstanceAsync(riveFile); + * ``` + * + * @example + * ```tsx + * // From RiveFile with specific instance name + * const { instance } = useViewModelInstanceAsync(riveFile, { instanceName: 'PersonInstance' }); + * ``` + * + * @example + * ```tsx + * // From RiveFile with specific ViewModel name + * const { instance } = useViewModelInstanceAsync(riveFile, { viewModelName: 'Settings' }); + * ``` + * + * @example + * ```tsx + * // Create a new blank instance from ViewModel + * const viewModel = await file.viewModelByNameAsync('TodoItem'); + * const { instance } = useViewModelInstanceAsync(viewModel, { useNew: true }); + * ``` + * + * @example + * ```tsx + * // With required: true (throws once resolved to null, use with Error Boundary). + * // Note: instance is still `undefined` while loading — guard on isLoading. + * const { instance, isLoading } = useViewModelInstanceAsync(riveFile, { required: true }); + * ``` + * + * @example + * ```tsx + * // With onInit to set initial values before the instance is exposed or bound + * const { instance } = useViewModelInstanceAsync(riveFile, { + * onInit: (vmi) => { + * vmi.numberProperty('count')?.set(initialCount); + * } + * }); + * ``` + */ +// RiveFile overloads +export function useViewModelInstanceAsync( + source: RiveFile, + params: UseViewModelInstanceFileParams & { required: true } +): UseViewModelInstanceAsyncRequiredResult; +export function useViewModelInstanceAsync( + source: RiveFile | null | undefined, + params?: UseViewModelInstanceFileParams +): UseViewModelInstanceAsyncResult; + +// ViewModel overloads +export function useViewModelInstanceAsync( + source: ViewModel, + params: UseViewModelInstanceViewModelParams & { required: true } +): UseViewModelInstanceAsyncRequiredResult; +export function useViewModelInstanceAsync( + source: ViewModel | null | undefined, + params?: UseViewModelInstanceViewModelParams +): UseViewModelInstanceAsyncResult; + +// RiveViewRef overloads +export function useViewModelInstanceAsync( + source: RiveViewRef, + params: UseViewModelInstanceRefParams & { required: true } +): UseViewModelInstanceAsyncRequiredResult; +export function useViewModelInstanceAsync( + source: RiveViewRef | null | undefined, + params?: UseViewModelInstanceRefParams +): UseViewModelInstanceAsyncResult; + +// Implementation +export function useViewModelInstanceAsync( + source: ViewModelSource | null | undefined, + params?: + | UseViewModelInstanceFileParams + | UseViewModelInstanceViewModelParams + | UseViewModelInstanceRefParams +): UseViewModelInstanceAsyncResult { + const fileInstanceName = (params as { instanceName?: string } | undefined) + ?.instanceName; + const viewModelInstanceName = (params as { name?: string } | undefined)?.name; + const instanceName = fileInstanceName ?? viewModelInstanceName; + const artboardName = (params as UseViewModelInstanceFileParams | undefined) + ?.artboardName; + const viewModelName = (params as UseViewModelInstanceFileParams | undefined) + ?.viewModelName; + const useNew = + (params as UseViewModelInstanceViewModelParams | undefined)?.useNew ?? + false; + const required = params?.required ?? false; + const onInit = params?.onInit; + + const onInitRef = useRef(onInit); + onInitRef.current = onInit; + + const [result, setResult] = + useState(LOADING_RESULT); + + // Reset to the loading state during render (not in the effect) when the + // inputs change: an effect-time reset would let React commit one frame + // pairing the new source with the previous (about-to-be-disposed) instance + // at isLoading: false — a mismatch consumer guards cannot catch, and the + // old instance would be disposed while still committed as e.g. a view's + // dataBind. Resetting mid-render makes React re-render before committing. + const [prevDeps, setPrevDeps] = useState([ + source, + instanceName, + artboardName, + viewModelName, + useNew, + ]); + const deps = [source, instanceName, artboardName, viewModelName, useNew]; + if (deps.some((d, i) => d !== prevDeps[i])) { + setPrevDeps(deps); + setResult((prev) => (prev.isLoading ? prev : LOADING_RESULT)); + } + + useEffect(() => { + if (source === null) { + // Source resolved to absent/failed rather than pending. `useRiveFile` + // returns `riveFile: null` on load error (vs `undefined` while loading), + // so settle to a terminal null instead of spinning forever — otherwise a + // consumer keying a spinner off `isLoading` hangs with no signal. The + // file's own `error` carries the reason. + setResult({ instance: null, isLoading: false, error: null }); + return; + } + + if (!source) { + // `undefined`: not resolved yet (e.g. the file is still loading). + return; + } + + let cancelled = false; + let created: CreateInstanceResult | null = null; + + (async () => { + try { + const c = await createInstanceAsync( + source, + instanceName, + artboardName, + viewModelName, + useNew, + () => cancelled + ); + created = c; + + if (cancelled) { + if (c.needsDispose && c.instance) callDispose(c.instance); + return; + } + + if (c.instance) { + try { + onInitRef.current?.(c.instance); + } catch (e) { + created = null; + if (c.needsDispose) callDispose(c.instance); + setResult({ + instance: null, + isLoading: false, + error: e instanceof Error ? e : new Error(String(e)), + }); + return; + } + setResult({ instance: c.instance, isLoading: false, error: null }); + } else if (c.error) { + setResult({ instance: null, isLoading: false, error: c.error }); + } else { + // Resolved, but there is genuinely no ViewModel (not an error). + setResult({ instance: null, isLoading: false, error: null }); + } + } catch (e) { + if (cancelled) return; + setResult({ + instance: null, + isLoading: false, + error: e instanceof Error ? e : new Error(String(e)), + }); + } + })(); + + return () => { + cancelled = true; + if (created?.needsDispose && created.instance) { + callDispose(created.instance); + } + }; + }, [source, instanceName, artboardName, viewModelName, useNew]); + + if (required && result.instance === null && !result.isLoading) { + // The public entry point is useViewModelInstance — don't leak this + // internal hook's name into user-facing errors. + if (source === null) { + throw new Error( + 'useViewModelInstance: source is null — the file or view failed to ' + + "resolve upstream (if it comes from useRiveFile or useRive, check that hook's error)." + ); + } + throw new Error( + result.error + ? `useViewModelInstance: ${result.error.message}` + : 'useViewModelInstance: Failed to get ViewModelInstance. ' + + 'Ensure the source has a valid ViewModel and instance available.' + ); + } + + return result; +} diff --git a/src/index.tsx b/src/index.tsx index 7e29e27a..92fea083 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -59,8 +59,10 @@ export { useRiveColor } from './hooks/useRiveColor'; export { useRiveTrigger } from './hooks/useRiveTrigger'; export { useRiveList } from './hooks/useRiveList'; export { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- only the non-async overloads are deprecated; the export itself is current useViewModelInstance, type UseViewModelInstanceResult, + type UseViewModelInstanceRequiredResult, } from './hooks/useViewModelInstance'; export { useRiveFile, type UseRiveFileResult } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; diff --git a/tsconfig.json b/tsconfig.json index 278a9b8f..9a2c358c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,5 @@ "target": "ESNext", "verbatimModuleSyntax": true }, - "exclude": ["expo-example", "expo55-example"] + "exclude": ["expo-example", "expo55-example", "**/*.test-d.ts"] } diff --git a/yarn.lock b/yarn.lock index f1bbc0a5..c372c698 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5492,6 +5492,7 @@ __metadata: react-native-nitro-modules: 0.35.10 react-test-renderer: 19.0.0 release-it: ^17.10.0 + tsd: ^0.33.0 turbo: ^1.10.7 typescript: ^5.2.2 peerDependencies: @@ -5828,6 +5829,13 @@ __metadata: languageName: node linkType: hard +"@tsd/typescript@npm:^5.9.2": + version: 5.9.3 + resolution: "@tsd/typescript@npm:5.9.3" + checksum: d1209e850cb36715b62305821f87877c11756cb0530d562f2d0c7f34feb34d6524c13d12f79f020c6bf69d8e3f5085deaef6657004937668c38f4986d82fa98f + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.0": version: 0.10.2 resolution: "@tybys/wasm-util@npm:0.10.2" @@ -5911,20 +5919,30 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.8": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: bd93e2e415b6f182ec4da1074e1f36c480f1d26add3e696d54fb30c09bc470897e41361c8fd957bf0985024f8fbf1e6e2aff977d79352ef7eb93a5c6dcff6c11 +"@types/eslint@npm:^7.2.13": + version: 7.29.0 + resolution: "@types/eslint@npm:7.29.0" + dependencies: + "@types/estree": "*" + "@types/json-schema": "*" + checksum: df13991c554954353ce8f3bb03e19da6cc71916889443d68d178d4f858b561ba4cc4a4f291c6eb9eebb7f864b12b9b9313051b3a8dfea3e513dadf3188a77bdf languageName: node linkType: hard -"@types/estree@npm:^1.0.6": +"@types/estree@npm:*, @types/estree@npm:^1.0.6": version: 1.0.9 resolution: "@types/estree@npm:1.0.9" checksum: 752c0afee3ec82b8e24484bf6a27dfa093bbf3de4ef1c20ed0364fb6ad2c0c7971e7504ed9a7aaff103a47e2d945ce7a17f74951743dd944782a0735f53170de languageName: node linkType: hard +"@types/estree@npm:1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: bd93e2e415b6f182ec4da1074e1f36c480f1d26add3e696d54fb30c09bc470897e41361c8fd957bf0985024f8fbf1e6e2aff977d79352ef7eb93a5c6dcff6c11 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.9 resolution: "@types/graceful-fs@npm:4.1.9" @@ -5976,7 +5994,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": +"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 97ed0cb44d4070aecea772b7b2e2ed971e10c81ec87dd4ecc160322ffa55ff330dace1793489540e3e318d90942064bb697cc0f8989391797792d919737b3b98 @@ -5990,7 +6008,7 @@ __metadata: languageName: node linkType: hard -"@types/minimist@npm:^1.2.2": +"@types/minimist@npm:^1.2.0, @types/minimist@npm:^1.2.2": version: 1.2.5 resolution: "@types/minimist@npm:1.2.5" checksum: 477047b606005058ab0263c4f58097136268007f320003c348794f74adedc3166ffc47c80ec3e94687787f2ab7f4e72c468223946e79892cf0fd9e25e9970a90 @@ -7707,6 +7725,17 @@ __metadata: languageName: node linkType: hard +"camelcase-keys@npm:^6.2.2": + version: 6.2.2 + resolution: "camelcase-keys@npm:6.2.2" + dependencies: + camelcase: ^5.3.1 + map-obj: ^4.0.0 + quick-lru: ^4.0.1 + checksum: 43c9af1adf840471e54c68ab3e5fe8a62719a6b7dbf4e2e86886b7b0ff96112c945736342b837bd2529ec9d1c7d1934e5653318478d98e0cf22c475c04658e2a + languageName: node + linkType: hard + "camelcase-keys@npm:^7.0.0": version: 7.0.2 resolution: "camelcase-keys@npm:7.0.2" @@ -9429,6 +9458,22 @@ __metadata: languageName: node linkType: hard +"eslint-formatter-pretty@npm:^4.1.0": + version: 4.1.0 + resolution: "eslint-formatter-pretty@npm:4.1.0" + dependencies: + "@types/eslint": ^7.2.13 + ansi-escapes: ^4.2.1 + chalk: ^4.1.0 + eslint-rule-docs: ^1.1.5 + log-symbols: ^4.0.0 + plur: ^4.0.0 + string-width: ^4.2.0 + supports-hyperlinks: ^2.0.0 + checksum: e8e0cd3843513fff32a70b036dd349fdab81d73b5e522f23685181c907a1faf2b2ebcae1688dc71d0fc026184011792f7e39b833d349df18fe2baea00d017901 + languageName: node + linkType: hard + "eslint-import-context@npm:^0.1.8": version: 0.1.9 resolution: "eslint-import-context@npm:0.1.9" @@ -9705,6 +9750,13 @@ __metadata: languageName: node linkType: hard +"eslint-rule-docs@npm:^1.1.5": + version: 1.1.235 + resolution: "eslint-rule-docs@npm:1.1.235" + checksum: b163596f9a05568e287b2c78f51a280092122a2e43c45fa2c200f0bd3f61877af186c641dab97620978bec96d9e2cfb621e51728044d9efe42ddc24f5a594b26 + languageName: node + linkType: hard + "eslint-scope@npm:5.1.1, eslint-scope@npm:^5.1.1": version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" @@ -11614,6 +11666,13 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^2.1.4": + version: 2.8.9 + resolution: "hosted-git-info@npm:2.8.9" + checksum: c955394bdab888a1e9bb10eb33029e0f7ce5a2ac7b3f158099dc8c486c99e73809dca609f5694b223920ca2174db33d32b12f9a2a47141dc59607c29da5a62dd + languageName: node + linkType: hard + "hosted-git-info@npm:^4.0.1": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0" @@ -11902,6 +11961,13 @@ __metadata: languageName: node linkType: hard +"irregular-plurals@npm:^3.2.0": + version: 3.5.0 + resolution: "irregular-plurals@npm:3.5.0" + checksum: 5b663091dc89155df7b2e9d053e8fb11941a0c4be95c4b6549ed3ea020489fdf4f75ea586c915b5b543704252679a5a6e8c6c3587da5ac3fc57b12da90a9aee7 + languageName: node + linkType: hard + "is-absolute@npm:^1.0.0": version: 1.0.0 resolution: "is-absolute@npm:1.0.0" @@ -12688,7 +12754,7 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.7.0": +"jest-diff@npm:^29.0.3, jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" dependencies: @@ -13630,7 +13696,7 @@ __metadata: languageName: node linkType: hard -"log-symbols@npm:^4.1.0": +"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0": version: 4.1.0 resolution: "log-symbols@npm:4.1.0" dependencies: @@ -13745,7 +13811,7 @@ __metadata: languageName: node linkType: hard -"map-obj@npm:^4.1.0": +"map-obj@npm:^4.0.0, map-obj@npm:^4.1.0": version: 4.3.0 resolution: "map-obj@npm:4.3.0" checksum: fbc554934d1a27a1910e842bc87b177b1a556609dd803747c85ece420692380827c6ae94a95cce4407c054fa0964be3bf8226f7f2cb2e9eeee432c7c1985684e @@ -13821,6 +13887,26 @@ __metadata: languageName: node linkType: hard +"meow@npm:^9.0.0": + version: 9.0.0 + resolution: "meow@npm:9.0.0" + dependencies: + "@types/minimist": ^1.2.0 + camelcase-keys: ^6.2.2 + decamelize: ^1.2.0 + decamelize-keys: ^1.1.0 + hard-rejection: ^2.1.0 + minimist-options: 4.1.0 + normalize-package-data: ^3.0.0 + read-pkg-up: ^7.0.1 + redent: ^3.0.0 + trim-newlines: ^3.0.0 + type-fest: ^0.18.0 + yargs-parser: ^20.2.3 + checksum: 99799c47247f4daeee178e3124f6ef6f84bde2ba3f37652865d5d8f8b8adcf9eedfc551dd043e2455cd8206545fd848e269c0c5ab6b594680a0ad4d3617c9639 + languageName: node + linkType: hard + "merge-options@npm:^3.0.4": version: 3.0.4 resolution: "merge-options@npm:3.0.4" @@ -15143,7 +15229,19 @@ __metadata: languageName: node linkType: hard -"normalize-package-data@npm:^3.0.2": +"normalize-package-data@npm:^2.5.0": + version: 2.5.0 + resolution: "normalize-package-data@npm:2.5.0" + dependencies: + hosted-git-info: ^2.1.4 + resolve: ^1.10.0 + semver: 2 || 3 || 4 || 5 + validate-npm-package-license: ^3.0.1 + checksum: 7999112efc35a6259bc22db460540cae06564aa65d0271e3bdfa86876d08b0e578b7b5b0028ee61b23f1cae9fc0e7847e4edc0948d3068a39a2a82853efc8499 + languageName: node + linkType: hard + +"normalize-package-data@npm:^3.0.0, normalize-package-data@npm:^3.0.2": version: 3.0.3 resolution: "normalize-package-data@npm:3.0.3" dependencies: @@ -15683,7 +15781,7 @@ __metadata: languageName: node linkType: hard -"parse-json@npm:^5.2.0": +"parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" dependencies: @@ -15882,6 +15980,15 @@ __metadata: languageName: node linkType: hard +"plur@npm:^4.0.0": + version: 4.0.0 + resolution: "plur@npm:4.0.0" + dependencies: + irregular-plurals: ^3.2.0 + checksum: fea2e903efca67cc5c7a8952fca3db46ae8d9e9353373b406714977e601a5d3b628bcb043c3ad2126c6ff0e73d8020bf43af30a72dd087eff1ec240eb13b90e1 + languageName: node + linkType: hard + "pngjs@npm:^3.3.0": version: 3.4.0 resolution: "pngjs@npm:3.4.0" @@ -16175,6 +16282,13 @@ __metadata: languageName: node linkType: hard +"quick-lru@npm:^4.0.1": + version: 4.0.1 + resolution: "quick-lru@npm:4.0.1" + checksum: bea46e1abfaa07023e047d3cf1716a06172c4947886c053ede5c50321893711577cb6119360f810cc3ffcd70c4d7db4069c3cee876b358ceff8596e062bd1154 + languageName: node + linkType: hard + "quick-lru@npm:^5.1.1": version: 5.1.1 resolution: "quick-lru@npm:5.1.1" @@ -16872,6 +16986,17 @@ __metadata: languageName: node linkType: hard +"read-pkg-up@npm:^7.0.0, read-pkg-up@npm:^7.0.1": + version: 7.0.1 + resolution: "read-pkg-up@npm:7.0.1" + dependencies: + find-up: ^4.1.0 + read-pkg: ^5.2.0 + type-fest: ^0.8.1 + checksum: e4e93ce70e5905b490ca8f883eb9e48b5d3cebc6cd4527c25a0d8f3ae2903bd4121c5ab9c5a3e217ada0141098eeb661313c86fa008524b089b8ed0b7f165e44 + languageName: node + linkType: hard + "read-pkg-up@npm:^8.0.0": version: 8.0.0 resolution: "read-pkg-up@npm:8.0.0" @@ -16883,6 +17008,18 @@ __metadata: languageName: node linkType: hard +"read-pkg@npm:^5.2.0": + version: 5.2.0 + resolution: "read-pkg@npm:5.2.0" + dependencies: + "@types/normalize-package-data": ^2.4.0 + normalize-package-data: ^2.5.0 + parse-json: ^5.0.0 + type-fest: ^0.6.0 + checksum: eb696e60528b29aebe10e499ba93f44991908c57d70f2d26f369e46b8b9afc208ef11b4ba64f67630f31df8b6872129e0a8933c8c53b7b4daf0eace536901222 + languageName: node + linkType: hard + "read-pkg@npm:^6.0.0": version: 6.0.0 resolution: "read-pkg@npm:6.0.0" @@ -17168,7 +17305,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.1.6, resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.2": +"resolve@npm:^1.1.6, resolve@npm:^1.10.0, resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.2": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -17207,7 +17344,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@^1.1.6#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.11#~builtin, resolve@patch:resolve@^1.22.2#~builtin": +"resolve@patch:resolve@^1.1.6#~builtin, resolve@patch:resolve@^1.10.0#~builtin, resolve@patch:resolve@^1.20.0#~builtin, resolve@patch:resolve@^1.22.11#~builtin, resolve@patch:resolve@^1.22.2#~builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#~builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -17508,6 +17645,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:2 || 3 || 4 || 5": + version: 5.7.2 + resolution: "semver@npm:5.7.2" + bin: + semver: bin/semver + checksum: fb4ab5e0dd1c22ce0c937ea390b4a822147a9c53dbd2a9a0132f12fe382902beef4fbf12cf51bb955248d8d15874ce8cd89532569756384f994309825f10b686 + languageName: node + linkType: hard + "semver@npm:7.6.3, semver@npm:~7.6.3": version: 7.6.3 resolution: "semver@npm:7.6.3" @@ -18529,6 +18675,13 @@ __metadata: languageName: node linkType: hard +"trim-newlines@npm:^3.0.0": + version: 3.0.1 + resolution: "trim-newlines@npm:3.0.1" + checksum: b530f3fadf78e570cf3c761fb74fef655beff6b0f84b29209bac6c9622db75ad1417f4a7b5d54c96605dcd72734ad44526fef9f396807b90839449eb543c6206 + languageName: node + linkType: hard + "trim-newlines@npm:^4.0.2": version: 4.1.1 resolution: "trim-newlines@npm:4.1.1" @@ -18583,6 +18736,23 @@ __metadata: languageName: node linkType: hard +"tsd@npm:^0.33.0": + version: 0.33.0 + resolution: "tsd@npm:0.33.0" + dependencies: + "@tsd/typescript": ^5.9.2 + eslint-formatter-pretty: ^4.1.0 + globby: ^11.0.1 + jest-diff: ^29.0.3 + meow: ^9.0.0 + path-exists: ^4.0.0 + read-pkg-up: ^7.0.0 + bin: + tsd: dist/cli.js + checksum: 2916bcfd9d46bbeaab09e28ca714d4792c64fe12eda1a31e3d0786546fe93a1d2aeba0af2a68be4f9b211d3466ecf4f0187e4dbe45fe9f3eb29c4690c98e3379 + languageName: node + linkType: hard + "tslib@npm:^1.8.1": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -18695,6 +18865,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.18.0": + version: 0.18.1 + resolution: "type-fest@npm:0.18.1" + checksum: e96dcee18abe50ec82dab6cbc4751b3a82046da54c52e3b2d035b3c519732c0b3dd7a2fa9df24efd1a38d953d8d4813c50985f215f1957ee5e4f26b0fe0da395 + languageName: node + linkType: hard + "type-fest@npm:^0.21.3": version: 0.21.3 resolution: "type-fest@npm:0.21.3" @@ -18702,6 +18879,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.6.0": + version: 0.6.0 + resolution: "type-fest@npm:0.6.0" + checksum: b2188e6e4b21557f6e92960ec496d28a51d68658018cba8b597bd3ef757721d1db309f120ae987abeeda874511d14b776157ff809f23c6d1ce8f83b9b2b7d60f + languageName: node + linkType: hard + "type-fest@npm:^0.7.1": version: 0.7.1 resolution: "type-fest@npm:0.7.1" @@ -18709,6 +18893,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.8.1": + version: 0.8.1 + resolution: "type-fest@npm:0.8.1" + checksum: d61c4b2eba24009033ae4500d7d818a94fd6d1b481a8111612ee141400d5f1db46f199c014766b9fa9b31a6a7374d96fc748c6d688a78a3ce5a33123839becb7 + languageName: node + linkType: hard + "type-fest@npm:^1.0.1, type-fest@npm:^1.2.1, type-fest@npm:^1.2.2": version: 1.4.0 resolution: "type-fest@npm:1.4.0" @@ -19652,7 +19843,7 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^20.2.9": +"yargs-parser@npm:^20.2.3, yargs-parser@npm:^20.2.9": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" checksum: 8bb69015f2b0ff9e17b2c8e6bfe224ab463dd00ca211eece72a4cd8a906224d2703fb8a326d36fdd0e68701e201b2a60ed7cf81ce0fd9b3799f9fe7745977ae3