diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04455bbd..276491ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -416,6 +416,45 @@ jobs: echo "=== System log for Metro/Node ===" xcrun simctl spawn booted log show --predicate 'process CONTAINS "node" OR process CONTAINS "metro"' --last 5m --style compact 2>&1 | tail -50 || true + - name: Debug - Collect iOS crash reports + if: failure() || cancelled() + run: | + set +e + UDID=$(xcrun simctl list devices | awk -F '[()]' '/Booted/{print $2; exit}') + echo "Booted simulator UDID: ${UDID:-}" + mkdir -p crash-reports + # Simulator app crashes are written to the host's DiagnosticReports by + # ReportCrash, and sometimes inside the simulator's own data dir. Grab + # RiveExample-named reports plus anything from the last 25 min as a fallback. + for dir in \ + "$HOME/Library/Logs/DiagnosticReports" \ + "$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Logs/DiagnosticReports"; do + [ -d "$dir" ] || continue + find "$dir" -type f \ + \( -iname 'RiveExample-*' -o \( \( -iname '*.ips' -o -iname '*.crash' \) -mmin -25 \) \) \ + -exec cp {} crash-reports/ \; 2>/dev/null + done + count=$(ls -1 crash-reports 2>/dev/null | wc -l | tr -d ' ') + echo "=== Collected ${count} crash report(s) ===" + for f in crash-reports/*; do + [ -e "$f" ] || continue + echo "---------------- ${f} ----------------" + cat "$f" + echo + done + if [ "${count}" = "0" ]; then + echo "No crash reports found (app may not have crashed, or reports were not flushed yet)." + fi + exit 0 + + - name: Upload iOS crash reports + if: failure() || cancelled() + uses: actions/upload-artifact@v4 + with: + name: ios-harness-crash-reports + path: crash-reports/ + if-no-files-found: ignore + test-harness-android: runs-on: ubuntu-latest timeout-minutes: 30 @@ -618,6 +657,45 @@ jobs: echo "=== Checking simulator logs for errors ===" xcrun simctl spawn booted log show --predicate 'processImagePath CONTAINS "RiveExample"' --last 5m --style compact 2>&1 | tail -200 || echo "No logs found" + - name: Debug - Collect iOS crash reports + if: failure() || cancelled() + run: | + set +e + UDID=$(xcrun simctl list devices | awk -F '[()]' '/Booted/{print $2; exit}') + echo "Booted simulator UDID: ${UDID:-}" + mkdir -p crash-reports + # Simulator app crashes are written to the host's DiagnosticReports by + # ReportCrash, and sometimes inside the simulator's own data dir. Grab + # RiveExample-named reports plus anything from the last 25 min as a fallback. + for dir in \ + "$HOME/Library/Logs/DiagnosticReports" \ + "$HOME/Library/Developer/CoreSimulator/Devices/$UDID/data/Library/Logs/DiagnosticReports"; do + [ -d "$dir" ] || continue + find "$dir" -type f \ + \( -iname 'RiveExample-*' -o \( \( -iname '*.ips' -o -iname '*.crash' \) -mmin -25 \) \) \ + -exec cp {} crash-reports/ \; 2>/dev/null + done + count=$(ls -1 crash-reports 2>/dev/null | wc -l | tr -d ' ') + echo "=== Collected ${count} crash report(s) ===" + for f in crash-reports/*; do + [ -e "$f" ] || continue + echo "---------------- ${f} ----------------" + cat "$f" + echo + done + if [ "${count}" = "0" ]; then + echo "No crash reports found (app may not have crashed, or reports were not flushed yet)." + fi + exit 0 + + - name: Upload iOS crash reports + if: failure() || cancelled() + uses: actions/upload-artifact@v4 + with: + name: ios-harness-legacy-crash-reports + path: crash-reports/ + if-no-files-found: ignore + test-harness-android-legacy: runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt index ae0ac506..dc16a330 100644 --- a/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/new/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -99,11 +99,15 @@ class HybridRiveFile( Artboard.fromFile(file) } val vmSource = ViewModelSource.DefaultForArtboard(artboard) + // getDefaultViewModelInfo throws when the artboard has no default + // ViewModel — a normal state for VM-less files (issue #189 fixture), not + // a failure. Resolve null like the legacy backend so callers get the + // documented "no ViewModel" result instead of a raw error. val vmInfo = try { file.getDefaultViewModelInfo(artboard) } catch (e: Exception) { runCatching { artboard.close() } - throw e + return null } return HybridViewModel(file, riveWorker, vmInfo.viewModelName, this, vmSource, ownedArtboard = artboard) } diff --git a/example/__tests__/autoplay.harness.tsx b/example/__tests__/autoplay.harness.tsx index c90f2782..7ccae3b9 100644 --- a/example/__tests__/autoplay.harness.tsx +++ b/example/__tests__/autoplay.harness.tsx @@ -397,9 +397,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__/reconfigure.harness.tsx b/example/__tests__/reconfigure.harness.tsx index 131dfbf8..5759d24d 100644 --- a/example/__tests__/reconfigure.harness.tsx +++ b/example/__tests__/reconfigure.harness.tsx @@ -14,6 +14,7 @@ import { Fit, type RiveFile, type RiveViewRef, + type ViewModelNumberProperty, } from '@rive-app/react-native'; const BOUNCING_BALL = require('../assets/rive/bouncing_ball.riv'); @@ -74,11 +75,17 @@ describe('RiveView reconfigure (file switch)', () => { await render(); await waitFor(() => expect(context.ref).not.toBeNull(), { timeout: 5000 }); - // Confirm bouncing_ball is animating via its ypos ViewModel property - const vmi1 = context.ref!.getViewModelInstance(); - expect(vmi1).not.toBeNull(); - const ypos1 = vmi1!.numberProperty('ypos'); - expect(ypos1).toBeDefined(); + // The 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. + let ypos1: ViewModelNumberProperty | undefined; + await waitFor( + () => { + ypos1 = context.ref!.getViewModelInstance()?.numberProperty('ypos'); + expect(ypos1).toBeDefined(); + }, + { timeout: 5000 } + ); const valueBefore = ypos1!.value; await delay(500); expect(ypos1!.value).not.toBe(valueBefore); @@ -93,11 +100,16 @@ describe('RiveView reconfigure (file switch)', () => { await delay(600); expect(context.error).toBeNull(); - // Animation should still be running on the reconfigured view - const vmi2 = context.ref!.getViewModelInstance(); - expect(vmi2).not.toBeNull(); - const ypos2 = vmi2!.numberProperty('ypos'); - expect(ypos2).toBeDefined(); + // Animation should still be running on the reconfigured view. The + // reconfigured instance also resolves asynchronously, so poll for it too. + let ypos2: ViewModelNumberProperty | undefined; + await waitFor( + () => { + ypos2 = context.ref!.getViewModelInstance()?.numberProperty('ypos'); + expect(ypos2).toBeDefined(); + }, + { timeout: 5000 } + ); const valueAfterSwitch = ypos2!.value; await delay(500); expect(ypos2!.value).not.toBe(valueAfterSwitch); diff --git a/example/__tests__/useViewModelInstance-async-e2e.harness.tsx b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx new file mode 100644 index 00000000..c72ba839 --- /dev/null +++ b/example/__tests__/useViewModelInstance-async-e2e.harness.tsx @@ -0,0 +1,462 @@ +import { + describe, + it, + expect, + render, + waitFor, + cleanup, +} from 'react-native-harness'; +import { useEffect, useState } from 'react'; +import { Platform, 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 new backend +// *rejects* on a bad artboard/instance name while the legacy backend resolves +// null/undefined. These pin the hook's contract across 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 { + expect(value).toBeDefined(); +} + +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 ─── +// The new backend throws (iOS `createArtboard`, Android `Artboard.fromFile`) +// while the legacy backend resolves undefined; 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 new iOS backend throws `invalidViewModelInstance`), absent +// when the lookup resolves null (the legacy backends, 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); + 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 () => { + // getViewModelInstance() returns null on Android experimental — auto-bind + // doesn't expose the VMI handle to JS yet (same skip as autoplay.harness). + const isAndroidExperimental = + Platform.OS === 'android' && + RiveFileFactory.getBackend() === 'experimental'; + if (isAndroidExperimental) return; + + 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 isAndroidExperimental = + Platform.OS === 'android' && + RiveFileFactory.getBackend() === 'experimental'; + if (isAndroidExperimental) return; + + 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 ─────── +// The new backend's getDefaultViewModelInfo throws when the artboard has no +// default ViewModel, which would surface a raw native error for a perfectly +// valid file (the issue #189 fixture); the legacy backend resolves null. The +// natives normalize this to 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/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 2a6eb0b5..47b72c16 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 0f0e39d1..5efd9998 100644 --- a/example/src/exercisers/NestedViewModelExample.tsx +++ b/example/src/exercisers/NestedViewModelExample.tsx @@ -41,7 +41,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/example/src/reproducers/ReloadRebind.tsx b/example/src/reproducers/ReloadRebind.tsx index b52b1f6c..3632d6cb 100644 --- a/example/src/reproducers/ReloadRebind.tsx +++ b/example/src/reproducers/ReloadRebind.tsx @@ -26,6 +26,7 @@ export default function ReloadRebind() { const { riveFile } = useRiveFile( require('../../assets/rive/quick_start.riv') ); + // eslint-disable-next-line @typescript-eslint/no-deprecated -- intentionally exercises the deprecated sync creation path const { instance: viewModelInstance } = useViewModelInstance(riveFile, { onInit: (vmi) => vmi.numberProperty('health')!.set(50), }); diff --git a/ios/new/HybridRiveFile.swift b/ios/new/HybridRiveFile.swift index 4477890a..7665e1a8 100644 --- a/ios/new/HybridRiveFile.swift +++ b/ios/new/HybridRiveFile.swift @@ -87,7 +87,13 @@ class HybridRiveFile: HybridRiveFileSpec { } let artboard = try await file.createArtboard(artboardName) - let vmInfo = try await file.getDefaultViewModelInfo(for: artboard) + // getDefaultViewModelInfo throws when the artboard has no default + // ViewModel — a normal state for VM-less files (issue #189 fixture), not + // a failure. Resolve nil like the legacy backend so callers get the + // documented "no ViewModel" result instead of a raw error. + guard let vmInfo = try? await file.getDefaultViewModelInfo(for: artboard) else { + return nil + } return HybridViewModel(file: file, vmName: vmInfo.viewModelName, worker: worker) } 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..3827159d --- /dev/null +++ b/src/hooks/__tests__/useViewModelInstance.async.test.tsx @@ -0,0 +1,609 @@ +import React from 'react'; +import { + renderHook, + render, + waitFor, + act, +} from '@testing-library/react-native'; +import { useViewModelInstance } 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 new backend *throws* on an unknown artboard name while the legacy + // 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 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('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()); + expect((onError.mock.calls[0][0] as Error).message).toContain( + 'NonExistent' + ); + consoleError.mockRestore(); + }); +}); 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 3dc655a6..baac3784 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; @@ -193,126 +204,169 @@ 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. + * + * With `async: true`, 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. + * 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 & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; + 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. */ +export function useViewModelInstance( + source: RiveFile, + params: UseViewModelInstanceFileParams & { async?: false; required: true } +): 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. */ export function useViewModelInstance( source: RiveFile | null | undefined, - params?: UseViewModelInstanceFileParams + params?: UseViewModelInstanceFileParams & { async?: false } ): UseViewModelInstanceResult; // ViewModel overloads export function useViewModelInstance( source: ViewModel, - params: UseViewModelInstanceViewModelParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; + 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. */ +export function useViewModelInstance( + source: ViewModel, + params: UseViewModelInstanceViewModelParams & { + async?: false; + required: true; + } +): 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. */ export function useViewModelInstance( source: ViewModel | null | undefined, - params?: UseViewModelInstanceViewModelParams + params?: UseViewModelInstanceViewModelParams & { async?: false } ): UseViewModelInstanceResult; // RiveViewRef overloads export function useViewModelInstance( source: RiveViewRef, - params: UseViewModelInstanceRefParams & { required: true } -): - | { instance: ViewModelInstance; error: null } - | { instance: undefined; error: null }; + 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. */ +export function useViewModelInstance( + source: RiveViewRef, + params: UseViewModelInstanceRefParams & { async?: false; required: true } +): 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. */ export function useViewModelInstance( source: RiveViewRef | null | undefined, - params?: UseViewModelInstanceRefParams + params?: UseViewModelInstanceRefParams & { async?: false } ): UseViewModelInstanceResult; // Implementation @@ -322,6 +376,28 @@ 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. + 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; @@ -377,10 +453,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..e11391ee --- /dev/null +++ b/src/hooks/useViewModelInstanceAsync.ts @@ -0,0 +1,422 @@ +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. Only some backends reject on a +// bad name (the new iOS backend throws `invalidViewModelInstance`); the +// legacy backends and Android's pre-check resolve null instead, so a missing +// `cause` is normal. 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) { + // The new backend *throws* on an unknown artboard name (iOS + // `createArtboard`, Android `Artboard.fromFile`) while the legacy + // backend resolves undefined — map the 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); + + 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; + } + + // Reset to the loading state whenever the inputs change so we never expose a + // stale (and about-to-be-disposed) instance from a previous resolution. + setResult((prev) => (prev.isLoading ? prev : LOADING_RESULT)); + + 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) { + throw new Error( + result.error + ? `useViewModelInstanceAsync: ${result.error.message}` + : 'useViewModelInstanceAsync: 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 edcc5535..0da565bc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -65,8 +65,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';