From 46f5fc8e9d2b08174d3b3aea2e2c778b741fe86e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Fri, 21 Aug 2026 10:03:53 +0200 Subject: [PATCH 1/3] test(ios): failing harness test for view recreated by Fabric Fabric deletes a component view when its subtree stops being mounted (display: 'none', or a screen frozen by enableFreeze) and recreates it from the same, unchanged ShadowNode. On iOS the recreated view is never configured: nitro's isDirty prop flags live on the shared Props object and the first view instance already consumed them, so the second one never gets its file, stays blank, and the ref JS holds points at a dead view. Fails on iOS until the nitro bump; passes on Android, which recreates nothing. --- example/__tests__/view-recreate.harness.tsx | 145 ++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 example/__tests__/view-recreate.harness.tsx diff --git a/example/__tests__/view-recreate.harness.tsx b/example/__tests__/view-recreate.harness.tsx new file mode 100644 index 00000000..2fa36d14 --- /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; + }); + trigger.trigger(); + await waitFor( + () => { + expect(fired).toBe(true); + }, + { timeout: 1000 } + ).catch(() => {}); + 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(); + }); +}); From ccd44ba294cd91dd64efa7bb11dec74798597b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 1 Sep 2026 09:18:43 +0200 Subject: [PATCH 2/3] fix: force-apply props when Fabric recreates a RiveView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric can delete a component view and recreate it from the same, unchanged ShadowNode (react-freeze / Suspense re-inserting a hidden screen, or a display:none toggle). Nitro 0.35 keeps per-prop isDirty flags on the shared Props object and the previous view instance already consumed them, so the recreated view applies nothing: no file, no artboard, and a hybridRef that never fires. Force-apply every prop on a view instance's first updateProps — the fix from #365, applied through nitrogen-postprocess so yarn nitrogen doesn't wipe it. Upstream nitro 0.37 fixes this properly by diffing old vs new props (mrousavy/nitro#1503, #1506, #1510); this shim and its injector go away with that bump, which lives in #372. Also gives acceptNullForOptionalProps an early-exit guard so running the postprocess twice no longer duplicates its patch. --- .../ios/c++/views/HybridRiveViewComponent.mm | 36 ++++--- scripts/nitrogen-postprocess.ts | 101 ++++++++++++++++-- 2 files changed, 118 insertions(+), 19 deletions(-) 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(); From 5de9ae7b662562ad9a7eace34ed2fbb440c24874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Wed, 9 Sep 2026 10:53:04 +0200 Subject: [PATCH 3/3] test(harness): re-fire the view-recreate trigger probe while waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oracle gave the trigger a single one-second window, which assumes the async auto-bind and the post-show remount finish quickly; on CI's slower emulator either window can expire, and both Android harness jobs failed on every attempt. Keep the listener attached and re-fire the trigger every 500ms for up to 15s instead. A dead view never dispatches no matter how often the trigger fires, so the probe still fails on the bug this test guards — verified against a build without the force-apply fix. --- example/__tests__/view-recreate.harness.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/example/__tests__/view-recreate.harness.tsx b/example/__tests__/view-recreate.harness.tsx index 2fa36d14..d191d4bb 100644 --- a/example/__tests__/view-recreate.harness.tsx +++ b/example/__tests__/view-recreate.harness.tsx @@ -94,13 +94,13 @@ async function triggerReachesListener( const removeListener = trigger.addListener(() => { fired = true; }); - trigger.trigger(); - await waitFor( - () => { - expect(fired).toBe(true); - }, - { timeout: 1000 } - ).catch(() => {}); + // 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;