diff --git a/example/__tests__/view-recreate.harness.tsx b/example/__tests__/view-recreate.harness.tsx new file mode 100644 index 00000000..d191d4bb --- /dev/null +++ b/example/__tests__/view-recreate.harness.tsx @@ -0,0 +1,145 @@ +import { + describe, + it, + expect, + render, + waitFor, + cleanup, +} from 'react-native-harness'; +import { useState } from 'react'; +import { View } from 'react-native'; +import { + RiveView, + RiveFileFactory, + Fit, + type RiveFile, + type RiveViewRef, +} from '@rive-app/react-native'; +import type { ViewModelInstance } from '@rive-app/react-native'; + +/** + * Fabric deletes a component view when its subtree stops being mounted + * (`display: 'none'`, or a react-native-screens screen frozen by + * `enableFreeze(true)`) and recreates it from the same, unchanged ShadowNode + * when the subtree comes back. The recreated view has to be configured from + * those props. + * + * On iOS it was not: nitro tracks props with `isDirty` flags stored on the + * shared Props object and clears them once applied, so the second view + * instance was handed a props object whose flags the first instance had + * already consumed. It never received its file, stayed blank forever, and the + * ref JS holds kept pointing at the dead view. See PR #365. + */ + +const QUICK_START = require('../assets/rive/quick_start.riv'); + +function expectDefined(value: T): asserts value is NonNullable { + expect(value).toBeDefined(); +} + +type TestContext = { + ref: RiveViewRef | null; + error: string | null; + setHidden: ((hidden: boolean) => void) | null; +}; + +// The visibility state lives here so that flipping it re-renders this +// component only, leaving the RiveView's own ShadowNode untouched — the +// situation a frozen screen creates. +function HideableRive({ + context, + file, + instance, +}: { + context: TestContext; + file: RiveFile; + instance: ViewModelInstance; +}) { + const [hidden, setHidden] = useState(false); + context.setHidden = setHidden; + + return ( + + ); +} + +// A trigger only reaches its listener while a live view advances the state +// machine the instance is bound to, which is what makes this a usable answer +// to "is the view still driving this data binding?". +async function triggerReachesListener( + instance: ViewModelInstance +): Promise { + const trigger = instance.triggerProperty('gameOver'); + expectDefined(trigger); + let fired = false; + const removeListener = trigger.addListener(() => { + fired = true; + }); + // Re-fire while waiting: a probe can land before the async auto-bind or the + // remount has finished on a slow emulator, and a fresh trigger costs + // nothing. A dead view never dispatches no matter how often it's fired. + for (let i = 0; i < 30 && !fired; i++) { + trigger.trigger(); + await new Promise((r) => setTimeout(r, 500)); + } + removeListener(); + trigger.dispose(); + return fired; +} + +describe('view recreated by Fabric (PR #365)', () => { + it('keeps driving its data binding after hide/show', async () => { + const file = await RiveFileFactory.fromSource(QUICK_START, undefined); + const vm = file.defaultArtboardViewModel(); + expectDefined(vm); + const instance = vm.createDefaultInstance(); + expectDefined(instance); + + const context: TestContext = { ref: null, error: null, setHidden: null }; + + await render( + + ); + + await waitFor( + () => { + expect(context.ref).not.toBeNull(); + }, + { timeout: 5000 } + ); + await context.ref!.awaitViewReady(); + + // Control: the trigger reaches its listener while the view is alive, so a + // failure below means the view stopped working, not that the probe never did. + expect(await triggerReachesListener(instance)).toBe(true); + + context.setHidden!(true); + await new Promise((r) => setTimeout(r, 400)); + context.setHidden!(false); + await new Promise((r) => setTimeout(r, 600)); + + expect(context.error).toBeNull(); + expect(await triggerReachesListener(instance)).toBe(true); + + cleanup(); + }); +}); diff --git a/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm b/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm index f1669805..1a6c09c7 100644 --- a/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm +++ b/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm @@ -30,6 +30,12 @@ + (BOOL)shouldBeRecycled; @implementation HybridRiveViewComponent { std::shared_ptr _hybridView; + // The cached props' isDirty flags were already consumed by the previous + // view instance when Fabric recreates this view from an unchanged + // ShadowNode, so updateProps would apply nothing and the fresh view would + // stay unconfigured (issue #365). Track whether this instance applied its + // props at least once. + BOOL _didApplyInitialProps; } + (void) load { @@ -69,61 +75,66 @@ - (void) updateProps:(const std::shared_ptr&)props auto& newViewProps = const_cast(newViewPropsConst); RNRive::HybridRiveViewSpec_cxx& swiftPart = _hybridView->getSwiftPart(); + // Force-apply all props the first time this view instance updates (see + // _didApplyInitialProps above). + const bool force = !_didApplyInitialProps; + _didApplyInitialProps = YES; + // 2. Update each prop individually swiftPart.beforeUpdate(); // artboardName: optional - if (newViewProps.artboardName.isDirty) { + if (force || newViewProps.artboardName.isDirty) { swiftPart.setArtboardName(newViewProps.artboardName.value); newViewProps.artboardName.isDirty = false; } // stateMachineName: optional - if (newViewProps.stateMachineName.isDirty) { + if (force || newViewProps.stateMachineName.isDirty) { swiftPart.setStateMachineName(newViewProps.stateMachineName.value); newViewProps.stateMachineName.isDirty = false; } // autoPlay: optional - if (newViewProps.autoPlay.isDirty) { + if (force || newViewProps.autoPlay.isDirty) { swiftPart.setAutoPlay(newViewProps.autoPlay.value); newViewProps.autoPlay.isDirty = false; } // file: hybrid-object - if (newViewProps.file.isDirty) { + if (force || newViewProps.file.isDirty) { swiftPart.setFile(newViewProps.file.value); newViewProps.file.isDirty = false; } // alignment: optional - if (newViewProps.alignment.isDirty) { + if (force || newViewProps.alignment.isDirty) { swiftPart.setAlignment(newViewProps.alignment.value); newViewProps.alignment.isDirty = false; } // fit: optional - if (newViewProps.fit.isDirty) { + if (force || newViewProps.fit.isDirty) { swiftPart.setFit(newViewProps.fit.value); newViewProps.fit.isDirty = false; } // layoutScaleFactor: optional - if (newViewProps.layoutScaleFactor.isDirty) { + if (force || newViewProps.layoutScaleFactor.isDirty) { swiftPart.setLayoutScaleFactor(newViewProps.layoutScaleFactor.value); newViewProps.layoutScaleFactor.isDirty = false; } // frameRate: optional - if (newViewProps.frameRate.isDirty) { + if (force || newViewProps.frameRate.isDirty) { swiftPart.setFrameRate(newViewProps.frameRate.value); newViewProps.frameRate.isDirty = false; } // semantics: optional - if (newViewProps.semantics.isDirty) { + if (force || newViewProps.semantics.isDirty) { swiftPart.setSemantics(newViewProps.semantics.value); newViewProps.semantics.isDirty = false; } // dataBind: optional - if (newViewProps.dataBind.isDirty) { + if (force || newViewProps.dataBind.isDirty) { swiftPart.setDataBind(newViewProps.dataBind.value); newViewProps.dataBind.isDirty = false; } // onError: function - if (newViewProps.onError.isDirty) { + if (force || newViewProps.onError.isDirty) { swiftPart.setOnError(newViewProps.onError.value); newViewProps.onError.isDirty = false; } @@ -131,7 +142,7 @@ - (void) updateProps:(const std::shared_ptr&)props swiftPart.afterUpdate(); // 3. Update hybridRef if it changed - if (newViewProps.hybridRef.isDirty) { + if (force || newViewProps.hybridRef.isDirty) { // hybridRef changed - call it with new this const auto& maybeFunc = newViewProps.hybridRef.value; if (maybeFunc.has_value()) { @@ -150,6 +161,7 @@ + (BOOL)shouldBeRecycled { - (void)prepareForRecycle { [super prepareForRecycle]; + _didApplyInitialProps = NO; RNRive::HybridRiveViewSpec_cxx& swiftPart = _hybridView->getSwiftPart(); swiftPart.maybePrepareForRecycle(); } diff --git a/scripts/nitrogen-postprocess.ts b/scripts/nitrogen-postprocess.ts index 0cec6394..70fb651e 100644 --- a/scripts/nitrogen-postprocess.ts +++ b/scripts/nitrogen-postprocess.ts @@ -10,6 +10,10 @@ const COMPONENT_FILE = join( ROOT, 'nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp' ); +const IOS_COMPONENT_FILE = join( + ROOT, + 'nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm' +); function makeHybridRiveViewManagerOpen() { if (!existsSync(MANAGER_FILE)) { @@ -44,6 +48,10 @@ function acceptNullForOptionalProps() { } const content = readFileSync(COMPONENT_FILE, 'utf-8'); + if (content.includes('value.isNull()')) { + console.log('HybridRiveViewComponent.cpp already accepts null props'); + return; + } const pattern = /^( *)return (CachedProp>)::fromRawValue\(\*runtime, value, (sourceProps\.\w+)\);$/gm; const updated = content.replace( @@ -53,13 +61,9 @@ function acceptNullForOptionalProps() { ); if (content === updated) { - if (content.includes('value.isNull()')) { - console.log('HybridRiveViewComponent.cpp already accepts null props'); - } else { - console.warn( - 'No optional CachedProp parse sites found in HybridRiveViewComponent.cpp — nitrogen output may have changed shape' - ); - } + console.warn( + 'No optional CachedProp parse sites found in HybridRiveViewComponent.cpp — nitrogen output may have changed shape' + ); return; } @@ -69,5 +73,88 @@ function acceptNullForOptionalProps() { ); } +// Fabric can recreate a component view from an unchanged ShadowNode (e.g. +// react-freeze / Suspense re-inserting a previously hidden screen, or a plain +// display:none toggle). Nitro 0.35's isDirty prop flags live on the shared +// Props object and were already consumed by the previous view instance, so the +// recreated view's updateProps applies nothing: no file, no artboard, and a +// hybridRef that never fires (issue #365). Force-apply every prop on a view +// instance's first updateProps. Fixed upstream in nitro 0.37 (the generated +// code diffs old vs new props instead) — drop this when bumping past 0.36. +function forceApplyPropsOnFreshComponentView() { + if (!existsSync(IOS_COMPONENT_FILE)) { + console.warn('HybridRiveViewComponent.mm not found, skipping'); + return; + } + + const content = readFileSync(IOS_COMPONENT_FILE, 'utf-8'); + if (content.includes('_didApplyInitialProps')) { + console.log( + 'HybridRiveViewComponent.mm already force-applies initial props' + ); + return; + } + + const ivarAnchor = `@implementation HybridRiveViewComponent { + std::shared_ptr _hybridView; +}`; + const updatePropsAnchor = ` auto& newViewProps = const_cast(newViewPropsConst); + RNRive::HybridRiveViewSpec_cxx& swiftPart = _hybridView->getSwiftPart(); +`; + const recycleAnchor = `- (void)prepareForRecycle { + [super prepareForRecycle];`; + const dirtyCheck = /if \((newViewProps\.\w+\.isDirty)\) \{/g; + + if ( + !content.includes(ivarAnchor) || + !content.includes(updatePropsAnchor) || + !content.includes(recycleAnchor) || + !dirtyCheck.test(content) + ) { + console.warn( + 'HybridRiveViewComponent.mm anchors not found — nitrogen output may have changed shape' + ); + return; + } + dirtyCheck.lastIndex = 0; + + const updated = content + .replace( + ivarAnchor, + `@implementation HybridRiveViewComponent { + std::shared_ptr _hybridView; + // The cached props' isDirty flags were already consumed by the previous + // view instance when Fabric recreates this view from an unchanged + // ShadowNode, so updateProps would apply nothing and the fresh view would + // stay unconfigured (issue #365). Track whether this instance applied its + // props at least once. + BOOL _didApplyInitialProps; +}` + ) + .replace( + updatePropsAnchor, + updatePropsAnchor + + ` + // Force-apply all props the first time this view instance updates (see + // _didApplyInitialProps above). + const bool force = !_didApplyInitialProps; + _didApplyInitialProps = YES; +` + ) + .replace(dirtyCheck, 'if (force || $1) {') + .replace( + recycleAnchor, + recycleAnchor + + ` + _didApplyInitialProps = NO;` + ); + + writeFileSync(IOS_COMPONENT_FILE, updated); + console.log( + 'Patched HybridRiveViewComponent.mm to force-apply props on a fresh view' + ); +} + makeHybridRiveViewManagerOpen(); acceptNullForOptionalProps(); +forceApplyPropsOnFreshComponentView();