diff --git a/example/__tests__/script-databinding.harness.tsx b/example/__tests__/script-databinding.harness.tsx new file mode 100644 index 00000000..f7d596dd --- /dev/null +++ b/example/__tests__/script-databinding.harness.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, render, cleanup } from 'react-native-harness'; +import { View } from 'react-native'; +import { + RiveView, + RiveFileFactory, + Fit, + type ViewModelInstance, +} from '@rive-app/react-native'; + +// ProbeModel { seed, isReady, observedSeed }: the script caches +// context:viewModel() in init, sets isReady = 1 there, and copies seed into +// observedSeed every advance. +// Source: https://gist.github.com/luismolina13/693ab81ea35dd59136bdf8c94cb45c44 +const SCRIPT_BINDING_PROBE = require('../assets/rive/script_binding_probe.riv'); + +function expectDefined(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); +} + +function number(instance: ViewModelInstance, name: string) { + const prop = instance.numberProperty(name); + expectDefined(prop); + return prop; +} + +async function waitForValues( + instance: ViewModelInstance, + expected: Record, + timeout = 5000 +) { + const names = Object.keys(expected); + const read = async () => + Object.fromEntries( + await Promise.all( + names.map(async (name) => [ + name, + await number(instance, name).getValueAsync(), + ]) + ) + ); + const deadline = Date.now() + timeout; + let actual = await read(); + while ( + names.some((name) => actual[name] !== expected[name]) && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 50)); + actual = await read(); + } + return actual; +} + +describe('Scripts see the dataBind instance (issue #414)', () => { + it('script init and advance run against the instance passed to dataBind', async () => { + const file = await RiveFileFactory.fromSource( + SCRIPT_BINDING_PROBE, + undefined + ); + const vm = await file.viewModelByNameAsync('ProbeModel'); + expectDefined(vm); + const instance = await vm.createDefaultInstanceAsync(); + expectDefined(instance); + await number(instance, 'seed').setValueAsync(42); + + await render( + + + + ); + + const expected = { isReady: 1, observedSeed: 42 }; + expect(await waitForValues(instance, expected)).toEqual(expected); + + cleanup(); + }); +}); diff --git a/example/assets/rive/script_binding_probe.riv b/example/assets/rive/script_binding_probe.riv new file mode 100644 index 00000000..baf103f2 Binary files /dev/null and b/example/assets/rive/script_binding_probe.riv differ diff --git a/example/assets/rive/script_binding_probe.riv.d.ts b/example/assets/rive/script_binding_probe.riv.d.ts new file mode 100644 index 00000000..d2be9625 --- /dev/null +++ b/example/assets/rive/script_binding_probe.riv.d.ts @@ -0,0 +1,28 @@ +// Generated by rive-gen-types — do not edit manually. @generated +/* eslint-disable */ +// Source: script_binding_probe.riv +import type { RiveFileSource } from '@rive-app/react-native'; +declare const asset: RiveFileSource<{ + schemaVersion: 1; + artboards: 'Binding Probe' | 'NuRiveWordmark01' | 'NuRiveBrandmark01' | 'Watermark'; + defaultArtboard: 'Binding Probe'; + stateMachines: { + 'Binding Probe': 'Main'; + 'NuRiveWordmark01': 'State Machine 1'; + 'NuRiveBrandmark01': 'State Machine 1'; + 'Watermark': 'State Machine 1'; + }; + enums: {}; + viewModels: { + ProbeModel: { + seed: 'number'; + observedSeed: 'number'; + isReady: 'number'; + }; + RiveCLIBootUp: { + + }; + }; + referencedAssets: {}; +}>; +export default asset; diff --git a/example/src/reproducers/Issue414ScriptBinding.tsx b/example/src/reproducers/Issue414ScriptBinding.tsx new file mode 100644 index 00000000..d1abaa22 --- /dev/null +++ b/example/src/reproducers/Issue414ScriptBinding.tsx @@ -0,0 +1,108 @@ +import { useCallback, useState } from 'react'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { + Fit, + RiveView, + useRiveFile, + useRiveNumber, + useViewModelInstance, + type ViewModelInstance, +} from '@rive-app/react-native'; +import { type Metadata } from '../shared/metadata'; + +/** + * Issue #414: a script that caches context:viewModel() in init must see the + * instance passed through dataBind, not the file's default instance. + * + * The host seeds seed = 42 before mounting. The script sets isReady = 1 in + * init and copies seed into observedSeed every frame, drawing green when it + * sees 42 and red otherwise. Expected: 42 / 1 / 42 and a green square. + */ + +function Probe({ instance }: { instance: ViewModelInstance }) { + const { riveFile } = useRiveFile( + require('../../assets/rive/script_binding_probe.riv') + ); + const { value: seed } = useRiveNumber('seed', instance); + const { value: isReady } = useRiveNumber('isReady', instance); + const { value: observedSeed } = useRiveNumber('observedSeed', instance); + const pass = seed === 42 && isReady === 1 && observedSeed === 42; + + return ( + <> + + seed={String(seed)} isReady={String(isReady)} observedSeed= + {String(observedSeed)} + + + {pass ? 'PASS' : 'FAIL (script bound to another instance)'} + + {riveFile ? ( + + ) : null} + + ); +} + +export default function Issue414ScriptBinding() { + const { riveFile, error } = useRiveFile( + require('../../assets/rive/script_binding_probe.riv') + ); + const onInit = useCallback((vmi: ViewModelInstance) => { + vmi.numberProperty('seed')?.set(42); + }, []); + const { instance } = useViewModelInstance(riveFile, { + viewModelName: 'ProbeModel', + async: true, + onInit, + }); + const [generation, setGeneration] = useState(0); + + return ( + + {error ? {String(error)} : null} + {instance ? ( + + ) : ( + Loading… + )} + setGeneration((g) => g + 1)} + > + Remount + + + ); +} + +Issue414ScriptBinding.metadata = { + name: 'Issue #414: script view model binding', + description: 'Scripts cached the default instance instead of dataBind', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fff', padding: 16 }, + values: { fontSize: 15, fontFamily: 'monospace' }, + verdict: { fontSize: 16, fontWeight: '700', marginVertical: 8 }, + pass: { color: '#2e7d32' }, + fail: { color: '#d32f2f' }, + rive: { width: 160, height: 160 }, + button: { + alignSelf: 'flex-start', + backgroundColor: '#007AFF', + paddingHorizontal: 20, + paddingVertical: 10, + borderRadius: 8, + marginTop: 16, + }, + buttonText: { color: '#fff', fontWeight: '600' }, +}); diff --git a/ios/new/RiveReactNativeView.swift b/ios/new/RiveReactNativeView.swift index 9c76e21d..82ddd62e 100644 --- a/ios/new/RiveReactNativeView.swift +++ b/ios/new/RiveReactNativeView.swift @@ -88,7 +88,6 @@ class RiveReactNativeView: UIView { guard let self else { return } do { let artboard = try await config.file.createArtboard(config.artboardName) - let stateMachine = try await artboard.createStateMachine(config.stateMachineName) let dataBind: RiveRuntime.DataBind switch config.bindData { @@ -113,6 +112,13 @@ class RiveReactNativeView: UIView { guard !Task.isCancelled else { return } + let stateMachine: StateMachine + if case .instance(let vmi) = dataBind { + stateMachine = try await artboard.createStateMachine(config.stateMachineName, binding: vmi) + } else { + stateMachine = try await artboard.createStateMachine(config.stateMachineName) + } + let rive = try await RiveRuntime.Rive( file: config.file, artboard: artboard,