From a4a10b56542f20447dc48b89e1603f66064afba9 Mon Sep 17 00:00:00 2001 From: Romain Lods Date: Tue, 18 Aug 2026 11:43:50 +0200 Subject: [PATCH 1/3] fix(ios): apply props on first updateProps of a recreated component view When Fabric recreates a component view from an unchanged ShadowNode (e.g. react-freeze/Suspense re-inserting a previously hidden screen), the shared Props object's isDirty flags were already consumed by the previous view instance, so updateProps applied nothing: the fresh HybridRiveView never received file/artboardName/hybridRef and rendered blank forever. Force-apply every prop on a view instance's first updateProps, keeping the isDirty fast path for subsequent updates. --- .../ios/c++/views/HybridRiveViewComponent.mm | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm b/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm index f1669805..5a4c0c6b 100644 --- a/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm +++ b/nitrogen/generated/ios/c++/views/HybridRiveViewComponent.mm @@ -30,6 +30,15 @@ + (BOOL)shouldBeRecycled; @implementation HybridRiveViewComponent { std::shared_ptr _hybridView; + // Fabric can recreate this component view from an unchanged ShadowNode + // (e.g. react-freeze / Suspense re-inserting a previously hidden screen). + // The cached props' isDirty flags were already consumed by the previous + // view instance (they live on the shared Props object and are mutated on + // first apply), so updateProps would apply nothing and the fresh + // HybridRiveView would stay unconfigured: no file, no artboard, and a + // hybridRef that never fires (JS keeps a ref to the dead old hybrid). + // Force-apply every prop on this instance's first updateProps. + BOOL _didApplyInitialProps; } + (void) load { @@ -69,61 +78,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). + 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 +145,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()) { From 64509821511b7d82c7847c6b2beeaef90f0852bf Mon Sep 17 00:00:00 2001 From: Romain Lods Date: Sat, 22 Aug 2026 19:32:25 +0200 Subject: [PATCH 2/3] fix(ios): apply the force-apply patch in nitrogen-postprocess so it survives regeneration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI regenerates nitrogen/generated/ and fails on any diff, so the force-apply-on-first-updateProps patch cannot live only in the generated HybridRiveViewComponent.mm. Apply it from scripts/nitrogen-postprocess.ts (alongside the existing nitro#1184 patch) so `yarn nitrogen` reproduces the committed file byte-for-byte. Also make acceptNullForOptionalProps idempotent: it used to re-insert its null-check lines when the script ran twice without regenerating first. The patch is fixed upstream in nitro 0.37 (mrousavy/nitro#1503, #1506, mrousavy/nitro#1510) — both postprocess patches can be dropped on the next nitro upgrade. Co-Authored-By: Claude Fable 5 --- scripts/nitrogen-postprocess.ts | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/scripts/nitrogen-postprocess.ts b/scripts/nitrogen-postprocess.ts index 0cec6394..f96d3b61 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( @@ -69,5 +77,74 @@ function acceptNullForOptionalProps() { ); } +// Fabric can recreate the iOS component view from an unchanged ShadowNode +// (e.g. react-freeze / Suspense re-inserting a previously hidden screen), but +// the cached props' isDirty flags were already consumed by the previous view +// instance (they live on the shared Props object and are mutated on first +// apply), so updateProps would apply nothing and the fresh view would stay +// unconfigured: no file, no artboard, and a hybridRef that never fires. +// Force-apply every prop on a view instance's first updateProps. +// Fixed upstream in nitro 0.37 (props are diffed against oldProps instead of +// mutating shared isDirty state) — remove on the next nitro upgrade: +// https://github.com/mrousavy/nitro/pull/1506 +const IVAR_BLOCK = ` // Fabric can recreate this component view from an unchanged ShadowNode + // (e.g. react-freeze / Suspense re-inserting a previously hidden screen). + // The cached props' isDirty flags were already consumed by the previous + // view instance (they live on the shared Props object and are mutated on + // first apply), so updateProps would apply nothing and the fresh + // HybridRiveView would stay unconfigured: no file, no artboard, and a + // hybridRef that never fires (JS keeps a ref to the dead old hybrid). + // Force-apply every prop on this instance's first updateProps. + BOOL _didApplyInitialProps; +`; + +const FORCE_BLOCK = ` + // Force-apply all props the first time this view instance updates (see + // _didApplyInitialProps above). + BOOL force = !_didApplyInitialProps; + _didApplyInitialProps = YES; +`; + +function forceApplyPropsOnFirstUpdate() { + 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 = + ' std::shared_ptr _hybridView;\n'; + const updateAnchor = '\n // 2. Update each prop individually\n'; + const dirtyPattern = /if \(newViewProps\.(\w+)\.isDirty\)/g; + if ( + !content.includes(ivarAnchor) || + !content.includes(updateAnchor) || + !dirtyPattern.test(content) + ) { + console.warn( + 'Anchors for the force-apply patch not found in HybridRiveViewComponent.mm — nitrogen output may have changed shape' + ); + return; + } + + const updated = content + .replace(ivarAnchor, `${ivarAnchor}${IVAR_BLOCK}`) + .replace(updateAnchor, `${FORCE_BLOCK}${updateAnchor}`) + .replace(dirtyPattern, 'if (force || newViewProps.$1.isDirty)'); + + writeFileSync(IOS_COMPONENT_FILE, updated); + console.log( + 'Patched HybridRiveViewComponent.mm to force-apply props on first update' + ); +} + makeHybridRiveViewManagerOpen(); acceptNullForOptionalProps(); +forceApplyPropsOnFirstUpdate(); From c4cf16d2c8e409d7fe99e08a0171742a8187df2e Mon Sep 17 00:00:00 2001 From: Romain Lods Date: Sat, 22 Aug 2026 20:14:47 +0200 Subject: [PATCH 3/3] chore: remove unreachable already-patched check in nitrogen-postprocess The top-level guard already returns when the file contains the value.isNull() marker, so the inner re-check could never be true. Co-Authored-By: Claude Fable 5 --- scripts/nitrogen-postprocess.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/scripts/nitrogen-postprocess.ts b/scripts/nitrogen-postprocess.ts index f96d3b61..7d08cf56 100644 --- a/scripts/nitrogen-postprocess.ts +++ b/scripts/nitrogen-postprocess.ts @@ -61,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; }