diff --git a/examples/public/data_binding_lists.riv b/examples/public/data_binding_lists.riv new file mode 100644 index 0000000..17ba61b Binary files /dev/null and b/examples/public/data_binding_lists.riv differ diff --git a/examples/public/global_variables_test.riv b/examples/public/global_variables_test.riv new file mode 100644 index 0000000..97ecdf4 Binary files /dev/null and b/examples/public/global_variables_test.riv differ diff --git a/examples/public/semantic_warning_exp4.riv b/examples/public/semantic_warning_exp4.riv new file mode 100644 index 0000000..5a207b6 Binary files /dev/null and b/examples/public/semantic_warning_exp4.riv differ diff --git a/examples/src/components/DataBindingHooks.stories.ts b/examples/src/components/DataBindingHooks.stories.ts new file mode 100644 index 0000000..24bc662 --- /dev/null +++ b/examples/src/components/DataBindingHooks.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import GlobalViewModelInstance from './DataBindingHooks'; + +const meta = { + title: 'Data Binding - Globals (with hooks)', + component: GlobalViewModelInstance, + parameters: { + layout: 'fullscreen', + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/examples/src/components/DataBindingHooks.tsx b/examples/src/components/DataBindingHooks.tsx new file mode 100644 index 0000000..51da0a7 --- /dev/null +++ b/examples/src/components/DataBindingHooks.tsx @@ -0,0 +1,77 @@ +import React, { useEffect } from 'react'; +import { + useRive, + useViewModel, + useViewModelInstance, + useGlobalViewModelInstance, + useViewModelInstanceColor, + useViewModelInstanceString, +} from '@rive-app/react-webgl2'; + +/** + * Set up main + global view model data binding with hooks (autoBind: false). + * + * The hooks run shortly after Rive loads — so the first frame has already + * advanced and rendered before they bind. If you need data set before anything + * renders, see the "Globals (setup before first frame)" example (onRiveReady). + */ +const GLOBAL_VM_COLORS = 'Colors'; +const COLOR_PROP = 'backgroundColor'; + +const GLOBAL_VM_CURRENCY = 'Labels'; +const CURRENCY_PROP = 'currency'; + + +const GlobalViewModelInstance = () => { + const { rive, RiveComponent } = useRive({ + src: 'global_variables_test.riv', + stateMachines: 'State Machine 1', + autoplay: false, + autoBind: false, + }); + + // Set up the main view model instance if you need a reference to it to change any properties. + // These two lines are optional here: the global hooks below trigger a bind(), and bind() fills + // any unset slots (including main) with their default instance — so the default main gets bound + // either way. (With autoBind:false and no globals, you'd need to set the main yourself.) + const mainViewModel = useViewModel(rive, { name: 'Main' }); + useViewModelInstance(mainViewModel, {rive}); + + // Set up global view model instances + // Note that we don't need to specify every global view model here - only the ones we want to have a reference to + // to change values on. The other globals will use a default instance. + const globalColorsViewModel = useViewModel(rive, { name: GLOBAL_VM_COLORS }); + const globalColorsInstance = useGlobalViewModelInstance( + globalColorsViewModel, + GLOBAL_VM_COLORS, + { rive } + ); + + const globalCurrencyViewModel = useViewModel(rive, { name: GLOBAL_VM_CURRENCY }); + const globalCurrencyInstance = useGlobalViewModelInstance( + globalCurrencyViewModel, + GLOBAL_VM_CURRENCY, + { rive } + ); + + const { value: bgColor, setValue: setBgColor } = useViewModelInstanceColor( + COLOR_PROP, + globalColorsInstance + ); + + const { value: currency, setValue: setCurrency } = useViewModelInstanceString( + CURRENCY_PROP, + globalCurrencyInstance + ); + + useEffect(() => { + setBgColor?.(parseInt('ff2266dd', 16)); + setCurrency?.('USD'); + // Play the animation again once the data is set + rive?.play(); + }, [setBgColor, setCurrency, rive]); + + return ; +}; + +export default GlobalViewModelInstance; diff --git a/examples/src/components/DataBindingPreRender.stories.ts b/examples/src/components/DataBindingPreRender.stories.ts new file mode 100644 index 0000000..48061d8 --- /dev/null +++ b/examples/src/components/DataBindingPreRender.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import GlobalViewModels from './DataBindingPreRender'; + +const meta = { + title: 'Data Binding - Globals (setup before first frame)', + component: GlobalViewModels, + parameters: { + layout: 'fullscreen', + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/examples/src/components/DataBindingPreRender.tsx b/examples/src/components/DataBindingPreRender.tsx new file mode 100644 index 0000000..dd9fbf3 --- /dev/null +++ b/examples/src/components/DataBindingPreRender.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { + useRive, + ViewModelInstanceString, + ViewModel, + ViewModelInstance, +} from '@rive-app/react-webgl2'; + +/** + * Set up main + global view model data binding before the first frame renders, + * using onRiveReady — the synchronous window (before the state machine advances) + * where you can initialize instances so the graphic starts with your data. + */ +const GLOBAL_VM_NAME = 'Labels'; +const CURRENCY_PROP = 'currency'; + +const GlobalViewModels = () => { + const { RiveComponent } = useRive({ + src: 'global_variables_test.riv', + stateMachines: 'State Machine 1', + autoplay: true, + // set autoBind to false to set up main+global instances before first frame manually + autoBind: false, + onRiveReady: (r) => { + const mainVm = r?.viewModelByName('Main') as ViewModel; + const globalLabelsVm = r?.viewModelByName('Labels') as ViewModel; + + if (mainVm) { + r.setViewModelInstance(mainVm.defaultInstance() as ViewModelInstance); + } + if (globalLabelsVm) { + const labelsInstance = globalLabelsVm.defaultInstance() as ViewModelInstance; + const currencyVal = labelsInstance.string(CURRENCY_PROP) as ViewModelInstanceString; + const alternateCurrencyVal = labelsInstance.string('alternateCurrency') as ViewModelInstanceString; + currencyVal.value = 'USD'; + alternateCurrencyVal.value = '¥'; + + r.setGlobalViewModelInstance(GLOBAL_VM_NAME, labelsInstance); + } + // Flush the data binding setup for main+globals with bind() + r.bind(); + }, + }); + + return ; +}; + +export default GlobalViewModels; diff --git a/examples/src/components/Semantics.stories.ts b/examples/src/components/Semantics.stories.ts new file mode 100644 index 0000000..9f999ef --- /dev/null +++ b/examples/src/components/Semantics.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import Semantics from './Semantics'; + +const meta = { + title: 'Semantics', + component: Semantics, + parameters: { + layout: 'fullscreen', + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/examples/src/components/Semantics.tsx b/examples/src/components/Semantics.tsx new file mode 100644 index 0000000..683d9a7 --- /dev/null +++ b/examples/src/components/Semantics.tsx @@ -0,0 +1,404 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + useRive, + EventType, + SemanticMode, + Fit, + Layout, +} from '@rive-app/react-canvas'; + +/** + * Testing semantics tree feature in Rive. Enable your accessibility tool to traverse the examples here (i.e. VoiceOver). + * Semantics is an opt-in feature in Rive, by passing in `semanticsMode: SemanticMode.Enabled` to `useRive`. + * + * @experimental The semantics API is early and may change without a major bump. + */ + +// The example .riv files, copied into examples/public. Each is loaded with the +// file's default artboard and a state machine named "State Machine 1". +const RIV_FILES = [ + 'data_binding_lists.riv', + 'semantic_warning_exp4.riv', +]; + +type FitValue = 'contain' | 'cover' | 'layout'; + +const FIT_BY_VALUE: Record = { + contain: Fit.Contain, + cover: Fit.Cover, + layout: Fit.Layout, +}; + +type LoadSource = { src: string } | { buffer: ArrayBuffer }; + +function buildLayout(fit: FitValue, scale: number): Layout { + const resolvedFit = FIT_BY_VALUE[fit]; + if (resolvedFit === Fit.Layout) { + return new Layout({ + fit: resolvedFit, + layoutScaleFactor: Number.isFinite(scale) && scale > 0 ? scale : 1, + }); + } + return new Layout({ fit: resolvedFit }); +} + +/** + * Wraps a single Rive instance for the currently selected source. The parent + * remounts this (via `key`) whenever the source changes + */ +interface RiveStageProps { + source: LoadSource; + label: string; + fit: FitValue; + scale: number; + onLog: (message: string) => void; +} + +const RiveStage = ({ source, label, fit, scale, onLog }: RiveStageProps) => { + const layout = useMemo(() => buildLayout(fit, scale), [fit, scale]); + + const { rive, RiveComponent } = useRive({ + ...source, + stateMachines: 'State Machine 1', + autoplay: true, + autoBind: true, + layout, + semanticsMode: SemanticMode.Enabled, + semanticsOptions: { + riveCanvasLabel: 'Rive animation', + }, + tabIndex: 0, + automaticallyHandleEvents: true, + onLoad: () => onLog(`Loaded ${label}`), + onLoadError: (e) => onLog(`Error loading ${label}: ${e}`), + }); + + // Re-apply the layout when fit / scale change, without remounting. + useEffect(() => { + if (!rive) return; + rive.layout = layout; + rive.resizeDrawingSurfaceToCanvas(); + }, [rive, layout]); + + return ( + + ); +}; + +const Semantics = () => { + const [source, setSource] = useState(null); + const [label, setLabel] = useState(''); + // Bumped on every load to force a fresh RiveStage (clean reload). + const [loadKey, setLoadKey] = useState(0); + const [fit, setFit] = useState('contain'); + const [scale, setScale] = useState(1); + const [logLines, setLogLines] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + + const fileInputRef = useRef(null); + const outputRef = useRef(null); + + const log = useCallback((message: string) => { + const timestamp = new Date().toLocaleTimeString(); + setLogLines((prev) => [...prev, `[${timestamp}] ${message}`]); + }, []); + + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [logLines]); + + const load = useCallback( + (nextSource: LoadSource, nextLabel: string) => { + setLogLines([]); + log(`Loading ${nextLabel}…`); + setSource(nextSource); + setLabel(nextLabel); + setLoadKey((k) => k + 1); + }, + [log] + ); + + const loadExample = useCallback( + (filename: string) => { + if (!filename) return; + load({ src: filename }, filename); + }, + [load] + ); + + const loadLocalFile = useCallback( + async (file: File) => { + if (!file.name.toLowerCase().endsWith('.riv')) { + log(`Ignored "${file.name}" — not a .riv file`); + return; + } + const buffer = await file.arrayBuffer(); + load({ buffer }, file.name); + }, + [load, log] + ); + + const onSelectChange = (e: React.ChangeEvent) => { + loadExample(e.target.value); + }; + + const onFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) loadLocalFile(file); + e.target.value = ''; + }; + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) loadLocalFile(file); + }; + + return ( +
+

Rive Semantics Test

+ +
+
+ + + + + {fit === 'layout' && ( + + )} + + +
+ +
{ + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={onDrop} + > + {source && ( +
+ +
+ )} + + {(!source || isDragOver) && ( +
fileInputRef.current?.click()} + > + Drop a .riv file here + + or click to browse — or pick an example above + +
+ )} +
+ +
+          {logLines.length
+            ? logLines.join('\n')
+            : 'Select an example or drop a .riv file to begin…'}
+        
+
+ + +
+ ); +}; + +// Inline styles mirroring the source app's dark theme. +const COLORS = { + bg: '#1a1a1a', + panel: '#252525', + border: '#3a3a3a', + text: '#e0e0e0', + textMuted: '#999', + accent: '#6c8eef', +}; + +const styles: Record = { + page: { + background: COLORS.bg, + color: COLORS.text, + fontFamily: 'system-ui, sans-serif', + minHeight: '100vh', + boxSizing: 'border-box', + padding: 24, + }, + h1: { + width: 'min(90%, 960px)', + margin: '0 auto 16px', + fontSize: 22, + fontWeight: 600, + }, + app: { + width: 'min(90%, 960px)', + margin: '0 auto', + display: 'flex', + flexDirection: 'column', + gap: 16, + }, + controls: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: 12, + background: COLORS.panel, + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + padding: 12, + }, + control: { + display: 'flex', + alignItems: 'center', + gap: 8, + }, + controlLabel: { + color: COLORS.textMuted, + fontSize: 14, + }, + select: { + background: COLORS.bg, + color: COLORS.text, + border: `1px solid ${COLORS.border}`, + borderRadius: 6, + padding: '6px 8px', + fontSize: 14, + width: '100%', + }, + input: { + background: COLORS.bg, + color: COLORS.text, + border: `1px solid ${COLORS.border}`, + borderRadius: 6, + padding: '6px 8px', + fontSize: 14, + }, + button: { + background: COLORS.bg, + color: COLORS.text, + border: `1px solid ${COLORS.border}`, + borderRadius: 6, + padding: '6px 12px', + fontSize: 14, + cursor: 'pointer', + }, + stage: { + position: 'relative', + height: 'min(70vh, 600px)', + background: COLORS.panel, + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + overflow: 'hidden', + }, + dropzone: { + position: 'absolute', + inset: 0, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + border: `2px dashed ${COLORS.border}`, + borderRadius: 8, + color: COLORS.text, + cursor: 'pointer', + textAlign: 'center', + }, + dropzoneActive: { + borderColor: COLORS.accent, + background: '#2b3040', + }, + dropzoneHint: { + color: COLORS.textMuted, + fontSize: 14, + }, + output: { + margin: 0, + maxHeight: 300, + overflow: 'auto', + background: COLORS.panel, + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + padding: 12, + fontFamily: 'ui-monospace, monospace', + fontSize: 13, + color: '#aaa', + whiteSpace: 'pre-wrap', + }, +}; + +export default Semantics; diff --git a/package-lock.json b/package-lock.json index 40dbc74..85bc764 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "4.29.5", "license": "MIT", "dependencies": { - "@rive-app/canvas": "2.38.5", - "@rive-app/canvas-lite": "2.38.5", - "@rive-app/webgl2": "2.38.5" + "@rive-app/canvas": "2.39.1", + "@rive-app/canvas-lite": "2.39.1", + "@rive-app/webgl2": "2.39.1" }, "devDependencies": { "@babel/core": "^7.18.0", @@ -1357,21 +1357,21 @@ } }, "node_modules/@rive-app/canvas": { - "version": "2.38.5", - "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.38.5.tgz", - "integrity": "sha512-S3W6w5D9m2JWD3IKmDYfOKppyVf197QSL4OyuIBiYOvwY552Gzu5ct+6vEevLCAXKhqiY2YuZIEMTXeUGOC5xg==", + "version": "2.39.1", + "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.39.1.tgz", + "integrity": "sha512-7Q2LNtFO4GaI78DZaH2aL6nI3EKFT5PIwj1XtFWzXYd6wUvCpc3fyk0WRzgX65DFMTdmbHl8EWVm+UEtzd8xXQ==", "license": "MIT" }, "node_modules/@rive-app/canvas-lite": { - "version": "2.38.5", - "resolved": "https://registry.npmjs.org/@rive-app/canvas-lite/-/canvas-lite-2.38.5.tgz", - "integrity": "sha512-N6clhjB8CYmIMmtYZN5mBSHJmaWiLKoFhFkHbhrkmHWMWYb1076K+NcKTHg8G7w6M/VTdzJ0ntuEMkGS7Egczw==", + "version": "2.39.1", + "resolved": "https://registry.npmjs.org/@rive-app/canvas-lite/-/canvas-lite-2.39.1.tgz", + "integrity": "sha512-0TNYD2QKF9S0k9+eB1LtArTCrzXPdecEQnrjdg2EXGuF7sDVcpUDP5cU4Hjf6ZqcsA+y05pWe9srhl0tUFsSOw==", "license": "MIT" }, "node_modules/@rive-app/webgl2": { - "version": "2.38.5", - "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.38.5.tgz", - "integrity": "sha512-CS0nuUZ0B1fXXk9uOQGGW4aU4qJSVs7uHvHVIkBsKV9QRGzFRlehssQ+znTfocBxbV6D1KCGKSR0uTvsbdINiQ==", + "version": "2.39.1", + "resolved": "https://registry.npmjs.org/@rive-app/webgl2/-/webgl2-2.39.1.tgz", + "integrity": "sha512-T/d2yieiQIStRatwB1G7G58tXTsXyeK91g2AhaPIGvi6SpdvQAJx7s6+ir42L4kiiTY5HeIBRXI+xueU+xzA3Q==", "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { diff --git a/package.json b/package.json index ecb10cf..58303b6 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ }, "homepage": "https://github.com/rive-app/rive-react#readme", "dependencies": { - "@rive-app/canvas": "2.38.5", - "@rive-app/canvas-lite": "2.38.5", - "@rive-app/webgl2": "2.38.5" + "@rive-app/canvas": "2.39.1", + "@rive-app/canvas-lite": "2.39.1", + "@rive-app/webgl2": "2.39.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0" diff --git a/src/bindScheduler.ts b/src/bindScheduler.ts new file mode 100644 index 0000000..f3b5ca8 --- /dev/null +++ b/src/bindScheduler.ts @@ -0,0 +1,29 @@ +import type { Rive } from '@rive-app/canvas'; + +// Rive instances with a bind already queued this tick. WeakSet so instances +// coalesce independently and entries free themselves on GC. +const pendingBinds = new WeakSet(); + +/** + * Coalesces `rive.bind()` (the flush that applies + * pending set*ViewModelInstance calls before the state machine advances). + * + * VM hooks call this instead of binding directly: the first call in a React + * commit queues a microtask that binds once; later calls that commit are + * deduped. So hooks resolving in the *same* commit share one bind — those across + * separate commits each bind. + * + * @param rive - The Rive instance to schedule a coalesced bind for. + */ +export function scheduleBind(rive: Rive): void { + if (pendingBinds.has(rive)) { + return; + } + pendingBinds.add(rive); + queueMicrotask(() => { + pendingBinds.delete(rive); + // bind() is a no-op internally if the instance was cleaned up in the + // interim (it guards on `destroyed`), so this is safe to call unconditionally. + rive.bind(); + }); +} diff --git a/src/hooks/useGlobalViewModelInstance.ts b/src/hooks/useGlobalViewModelInstance.ts new file mode 100644 index 0000000..7548dbc --- /dev/null +++ b/src/hooks/useGlobalViewModelInstance.ts @@ -0,0 +1,53 @@ +import { useState, useEffect } from 'react'; +import type { ViewModel, ViewModelInstance } from '@rive-app/canvas'; +import { UseGlobalViewModelInstanceParameters } from '../types'; +import { scheduleBind } from '../bindScheduler'; +import { resolveViewModelInstance } from '../resolveViewModelInstance'; + +/** + * Creates and binds a *global* view model instance. Resolves an + * instance from `viewModel` — default, or `{ useNew }` / `{ instanceName }` / + * `{ instance }` (a caller-supplied view model instance). When + * `params.rive` is set, registers it as the global `name` and schedules a + * coalesced bind (see {@link scheduleBind}). + * + * To read an already-bound global view model instance instead, query rive directly + * (rive.globalViewModelInstance(name)). + * + * @param viewModel - ViewModel to resolve from (e.g. from useViewModel). May be + * null while it loads, or when supplying `params.instance` directly. + * @param name - Name of the global view model to register under. + * @param params - Which instance to resolve, and the Rive instance to register with. + * @returns The resolved ViewModelInstance, or null if unavailable. + */ +export default function useGlobalViewModelInstance( + viewModel: ViewModel | null, + name: string, + params?: UseGlobalViewModelInstanceParameters +): ViewModelInstance | null { + const { instanceName, useNew = false, instance, rive } = params ?? {}; + const [resolvedInstance, setResolvedInstance] = + useState(null); + + useEffect(() => { + // A null viewModel (with no supplied instance) means "not ready yet". + const result = resolveViewModelInstance(viewModel, { + name: instanceName, + useNew, + instance, + }); + + setResolvedInstance(result); + + // Register as the global for `name` and schedule a coalesced bind + // (deduped with any other view model hook binding in the same commit). + if (rive && name && result) { + const didSet = rive.setGlobalViewModelInstance(name, result); + if (didSet) { + scheduleBind(rive); + } + } + }, [viewModel, name, instanceName, useNew, instance, rive]); + + return resolvedInstance; +} diff --git a/src/hooks/useViewModelInstance.ts b/src/hooks/useViewModelInstance.ts index b8c0516..767387e 100644 --- a/src/hooks/useViewModelInstance.ts +++ b/src/hooks/useViewModelInstance.ts @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react'; -import { ViewModel, ViewModelInstance } from '@rive-app/canvas'; +import type { ViewModel, ViewModelInstance } from '@rive-app/canvas'; import { UseViewModelInstanceParameters } from '../types'; +import { scheduleBind } from '../bindScheduler'; +import { resolveViewModelInstance } from '../resolveViewModelInstance'; /** * Hook for fetching a ViewModelInstance from a ViewModel. @@ -26,22 +28,18 @@ export default function useViewModelInstance( return; } - let result: ViewModelInstance | null = null; - - if (name != null) { - result = viewModel.instanceByName(name) || null; - } else if (useDefault) { - result = viewModel.defaultInstance?.() || null; - } else if (useNew) { - result = viewModel.instance?.() || null; - } else { - result = viewModel.defaultInstance?.() || null; - } + // useDefault is the implicit default, so it needs no dedicated branch. + const result = resolveViewModelInstance(viewModel, { name, useNew }); setInstance(result); if (rive && result && rive.viewModelInstance !== result) { - rive.bindViewModelInstance(result); + // Set the main instance (cheap) and schedule a coalesced bind() — + // deduped with any other view model hook binding in the same commit, + // rather than a full bind() per set. Equivalent to the previous + // bindViewModelInstance(result), just batched. + rive.setViewModelInstance(result); + scheduleBind(rive); } }, [viewModel, name, useDefault, useNew, rive]); diff --git a/src/index.ts b/src/index.ts index 67847d3..3ea3b50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import useRive from './hooks/useRive'; import useStateMachineInput from './hooks/useStateMachineInput'; import useViewModel from './hooks/useViewModel'; import useViewModelInstance from './hooks/useViewModelInstance'; +import useGlobalViewModelInstance from './hooks/useGlobalViewModelInstance'; import useViewModelInstanceNumber from './hooks/useViewModelInstanceNumber'; import useViewModelInstanceString from './hooks/useViewModelInstanceString'; import useViewModelInstanceBoolean from './hooks/useViewModelInstanceBoolean'; @@ -23,6 +24,7 @@ export { useRiveFile, useViewModel, useViewModelInstance, + useGlobalViewModelInstance, useViewModelInstanceNumber, useViewModelInstanceString, useViewModelInstanceBoolean, diff --git a/src/resolveViewModelInstance.ts b/src/resolveViewModelInstance.ts new file mode 100644 index 0000000..ff3ba99 --- /dev/null +++ b/src/resolveViewModelInstance.ts @@ -0,0 +1,34 @@ +import type { ViewModel, ViewModelInstance } from '@rive-app/canvas'; + +/** + * Resolves a ViewModelInstance from a ViewModel. Shared by useViewModelInstance + * and useGlobalViewModelInstance so the selector lives in one place. + * + * - `instance` present (even as null) is used directly — the caller-supplied + * path; a null value means "not ready yet". + * - otherwise, with a view model: `name` -> instanceByName, `useNew` -> a new + * instance, else the default instance. A null view model resolves to null. + */ +export function resolveViewModelInstance( + viewModel: ViewModel | null, + params: { + name?: string | null; + useNew?: boolean; + instance?: ViewModelInstance | null; + } +): ViewModelInstance | null { + const { name, useNew, instance } = params; + if (instance !== undefined) { + return instance; + } + if (!viewModel) { + return null; + } + if (name != null) { + return viewModel.instanceByName(name) || null; + } + if (useNew) { + return viewModel.instance?.() || null; + } + return viewModel.defaultInstance?.() || null; +} diff --git a/src/types.ts b/src/types.ts index 21f1322..fe6d260 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,21 @@ export type UseViewModelInstanceParameters = | { useDefault?: boolean; name?: never; useNew?: never; rive?: Rive | null } | { useNew?: boolean; name?: never; useDefault?: never; rive?: Rive | null }; +/** + * Parameters for useGlobalViewModelInstance. Selects which instance to resolve + * from the view model (default when none given); `rive` binds it. + * + * @property instanceName - Resolve the view model instance with this instance name. + * @property useNew - Create a new blank instance of the view model. + * @property instance - Register this caller-supplied instance directly. May be null while it loads. + * @property rive - Registers the instance as the global and schedules a coalesced + * bind. Required for the hook to register/bind anything. + */ +export type UseGlobalViewModelInstanceParameters = + | { instanceName: string; useNew?: never; instance?: never; rive?: Rive | null } + | { useNew?: boolean; instanceName?: never; instance?: never; rive?: Rive | null } + | { instance?: ViewModelInstance | null; instanceName?: never; useNew?: never; rive?: Rive | null }; + /** * Parameters for interacting with trigger properties of a ViewModelInstance * @property onTrigger - Callback that runs when the trigger fires diff --git a/test/bindScheduler.test.tsx b/test/bindScheduler.test.tsx new file mode 100644 index 0000000..1ee0b5e --- /dev/null +++ b/test/bindScheduler.test.tsx @@ -0,0 +1,48 @@ +import { scheduleBind } from '../src/bindScheduler'; + +// bindScheduler only imports Rive as a type; no runtime canvas dependency. +jest.mock('@rive-app/canvas', () => ({})); + +// queueMicrotask callbacks drain before an awaited resolved promise. +const flushMicrotasks = () => Promise.resolve(); + +function makeRive() { + return { bind: jest.fn() } as any; +} + +describe('scheduleBind', () => { + it('does not bind synchronously; coalesces same-tick calls into one bind()', async () => { + const rive = makeRive(); + + scheduleBind(rive); + scheduleBind(rive); + scheduleBind(rive); + expect(rive.bind).not.toHaveBeenCalled(); + + await flushMicrotasks(); + expect(rive.bind).toHaveBeenCalledTimes(1); + }); + + it('binds each Rive instance independently', async () => { + const a = makeRive(); + const b = makeRive(); + + scheduleBind(a); + scheduleBind(b); + await flushMicrotasks(); + + expect(a.bind).toHaveBeenCalledTimes(1); + expect(b.bind).toHaveBeenCalledTimes(1); + }); + + it('schedules a fresh bind after the microtask has flushed', async () => { + const rive = makeRive(); + + scheduleBind(rive); + await flushMicrotasks(); + scheduleBind(rive); + await flushMicrotasks(); + + expect(rive.bind).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/useGlobalViewModelInstance.test.tsx b/test/useGlobalViewModelInstance.test.tsx new file mode 100644 index 0000000..64fe70b --- /dev/null +++ b/test/useGlobalViewModelInstance.test.tsx @@ -0,0 +1,109 @@ +import { renderHook } from '@testing-library/react'; + +import useGlobalViewModelInstance from '../src/hooks/useGlobalViewModelInstance'; +import { scheduleBind } from '../src/bindScheduler'; + +jest.mock('@rive-app/canvas', () => ({})); +jest.mock('../src/bindScheduler', () => ({ scheduleBind: jest.fn() })); + +const mockScheduleBind = scheduleBind as jest.Mock; + +const makeInstance = (name: string) => ({ name } as any); + +function makeViewModel(overrides: Record = {}) { + return { + name: 'VM', + defaultInstance: jest.fn(() => makeInstance('default')), + instance: jest.fn(() => makeInstance('new')), + instanceByName: jest.fn((n: string) => makeInstance(n)), + ...overrides, + } as any; +} + +function makeRive(overrides: Record = {}) { + return { + setGlobalViewModelInstance: jest.fn(() => true), + viewModelByName: jest.fn(() => makeViewModel()), + ...overrides, + } as any; +} + +beforeEach(() => jest.clearAllMocks()); + +describe('useGlobalViewModelInstance', () => { + it('returns null when there is nothing to resolve from', () => { + const { result } = renderHook(() => + useGlobalViewModelInstance(null, 'Colors') + ); + expect(result.current).toBeNull(); + }); + + it('resolves the default instance with no resolution param', () => { + const vm = makeViewModel(); + const { result } = renderHook(() => + useGlobalViewModelInstance(vm, 'Colors') + ); + expect(vm.defaultInstance).toHaveBeenCalled(); + expect(result.current).toEqual({ name: 'default' }); + }); + + it('resolves a new instance with { useNew }', () => { + const vm = makeViewModel(); + const { result } = renderHook(() => + useGlobalViewModelInstance(vm, 'Colors', { useNew: true }) + ); + expect(vm.instance).toHaveBeenCalled(); + expect(result.current).toEqual({ name: 'new' }); + }); + + it('resolves a named instance with { instanceName }', () => { + const vm = makeViewModel(); + const { result } = renderHook(() => + useGlobalViewModelInstance(vm, 'Colors', { instanceName: 'Blue' }) + ); + expect(vm.instanceByName).toHaveBeenCalledWith('Blue'); + expect(result.current).toEqual({ name: 'Blue' }); + }); + + it('registers the instance and schedules a bind when rive is provided', () => { + const vm = makeViewModel(); + const rive = makeRive(); + renderHook(() => useGlobalViewModelInstance(vm, 'Colors', { rive })); + expect(rive.setGlobalViewModelInstance).toHaveBeenCalledWith( + 'Colors', + expect.objectContaining({ name: 'default' }) + ); + expect(mockScheduleBind).toHaveBeenCalledWith(rive); + }); + + it('does not schedule a bind when the name is not a global (set returns false)', () => { + const vm = makeViewModel(); + const rive = makeRive({ setGlobalViewModelInstance: jest.fn(() => false) }); + renderHook(() => useGlobalViewModelInstance(vm, 'NotAGlobal', { rive })); + expect(mockScheduleBind).not.toHaveBeenCalled(); + }); + + it('registers a caller-supplied instance directly, without fetching a view model', () => { + const rive = makeRive(); + const external = makeInstance('external'); + renderHook(() => + useGlobalViewModelInstance(null, 'Colors', { rive, instance: external }) + ); + expect(rive.viewModelByName).not.toHaveBeenCalled(); + expect(rive.setGlobalViewModelInstance).toHaveBeenCalledWith( + 'Colors', + external + ); + }); + + it('waits (no fetch, no register) when viewModel is null and no instance is supplied', () => { + const rive = makeRive(); + const { result } = renderHook(() => + useGlobalViewModelInstance(null, 'Colors', { rive }) + ); + expect(result.current).toBeNull(); + expect(rive.viewModelByName).not.toHaveBeenCalled(); + expect(rive.setGlobalViewModelInstance).not.toHaveBeenCalled(); + expect(mockScheduleBind).not.toHaveBeenCalled(); + }); +}); diff --git a/test/useViewModelInstance.test.tsx b/test/useViewModelInstance.test.tsx new file mode 100644 index 0000000..c488a58 --- /dev/null +++ b/test/useViewModelInstance.test.tsx @@ -0,0 +1,47 @@ +import { renderHook } from '@testing-library/react'; + +import useViewModelInstance from '../src/hooks/useViewModelInstance'; +import { scheduleBind } from '../src/bindScheduler'; + +jest.mock('@rive-app/canvas', () => ({})); +jest.mock('../src/bindScheduler', () => ({ scheduleBind: jest.fn() })); + +const mockScheduleBind = scheduleBind as jest.Mock; + +const makeViewModel = (defaultInstance: unknown) => + ({ defaultInstance: jest.fn(() => defaultInstance) } as any); + +const makeRive = (overrides: Record = {}) => + ({ viewModelInstance: null, setViewModelInstance: jest.fn(), ...overrides } as any); + +beforeEach(() => jest.clearAllMocks()); + +describe('useViewModelInstance binding', () => { + it('sets the main instance and schedules a coalesced bind when rive is passed', () => { + const instance = { name: 'default' }; + const vm = makeViewModel(instance); + const rive = makeRive(); + + renderHook(() => useViewModelInstance(vm, { rive })); + + expect(rive.setViewModelInstance).toHaveBeenCalledWith(instance); + expect(mockScheduleBind).toHaveBeenCalledWith(rive); + }); + + it('does not bind when no rive is passed', () => { + const vm = makeViewModel({ name: 'default' }); + renderHook(() => useViewModelInstance(vm)); + expect(mockScheduleBind).not.toHaveBeenCalled(); + }); + + it('does not re-set/bind when the resolved instance is already the same as the bound main', () => { + const instance = { name: 'default' }; + const vm = makeViewModel(instance); + const rive = makeRive({ viewModelInstance: instance }); + + renderHook(() => useViewModelInstance(vm, { rive })); + + expect(rive.setViewModelInstance).not.toHaveBeenCalled(); + expect(mockScheduleBind).not.toHaveBeenCalled(); + }); +});