Skip to content

HybridView props are not re-applied when Fabric recreates the native view #1380

Description

@RuudBurger

What happened?

Environment

  • nitrogen / react-native-nitro-modules: 0.35.4 (verified identical on 0.35.9)
  • React Native 0.83, new architecture (Fabric), iOS (Android shares the same mechanism)
  • Trigger: react-native-screens detaching and re-attaching a screen, which is what @react-navigation's JS createStackNavigator does.

Summary

When Fabric destroys and recreates the native component view for a Nitro HybridView whose shadow node did not change, the freshly created native view never receives its props and renders with default values. React Native core views are immune because they re-apply props by value diff on mount. Nitro applies props only when a one-shot isDirty flag is set, and that flag is already false by the time the new view is created.

I can make a PR for this, just wanted to check if this all seems like the proper way to fix it.

Root cause

Nitro props are CachedProp<T> (packages/.../cpp/views/CachedProp.hpp):

template <typename T> struct CachedProp {
  T value;
  bool isDirty = false;
  CachedProp(T&& value, ...) : value(...), isDirty(true) ... // dirty only when the value CHANGES
  static fromRawValue(rt, value, oldProp) {
    if (oldProp.equals(rt, value)) return oldProp;            // unchanged: reuse old (isDirty == false)
    return CachedProp<T>(...);                                // changed: new (isDirty == true)
  }
};

The generated updateProps (iOS: nitrogen/src/views/swift/SwiftHybridViewManager.ts; Android C++ updater: nitrogen/src/views/kotlin/KotlinHybridViewManager.ts) applies each prop only when isDirty, then sets isDirty = false on the shared shadow props:

if (newViewProps.uri.isDirty) {
  swiftPart.setUri(newViewProps.uri.value);
  newViewProps.uri.isDirty = false;
}

So isDirty tracks "changed in the shadow tree clone chain", not "needs applying to this native view". Once applied for a node it stays false. When Fabric later builds a new component view (new hybrid instance with default props) for that same unchanged node, every prop is isDirty == false, no setters run, and the new view keeps its defaults. RN core's updateProps(newProps, oldProps) compares values against oldProps (default for a fresh view), so it re-applies everything on mount.

Reproduction

Single App.tsx for the example/ app (uses the existing TestView from react-native-nitro-test and react-native-screens, both already present). TestView renders systemBlue when isBlue is true and systemRed otherwise (the Swift default is false).

// App.tsx
import * as React from 'react'
import { Button, StyleSheet, Text, View } from 'react-native'
import { ScreenContainer, Screen, enableScreens } from 'react-native-screens'
import { TestView } from 'react-native-nitro-test'

enableScreens()

export default function App() {
  const [onDetail, setOnDetail] = React.useState(false)

  return (
    <ScreenContainer style={StyleSheet.absoluteFill}>
      {/* "List" screen. activityState 0 makes react-native-screens detach it
          (Fabric later recreates its native subtree) while detail is shown. */}
      <Screen activityState={onDetail ? 0 : 2} style={StyleSheet.absoluteFill}>
        <View style={styles.center}>
          <Text>This box should stay BLUE the whole time:</Text>
          {/* Blue on first mount. After returning from detail it turns RED,
              because the recreated native view never gets isBlue re-applied. */}
          <TestView isBlue={true} style={styles.box} />
          <Button title="Go to detail" onPress={() => setOnDetail(true)} />
        </View>
      </Screen>

      {onDetail && (
        <Screen activityState={2} style={StyleSheet.absoluteFill}>
          <View style={styles.center}>
            <Text>Detail. Now press Back.</Text>
            <Button title="Back" onPress={() => setOnDetail(false)} />
          </View>
        </Screen>
      )}
    </ScreenContainer>
  )
}

const styles = StyleSheet.create({
  center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24 },
  box: { width: 120, height: 120 },
})

Steps:

  1. Launch. The box is blue (isBlue={true}).
  2. Tap "Go to detail", then tap "Back".
  3. The box is now red. The native view was recreated for the unchanged shadow node, and isBlue was never re-applied. A JS re-render does not recover it, only a full remount does.

Expected vs actual

  • Expected: the box stays blue after returning.
  • Actual: the box is red (default props), because the recreated native view never received isBlue.

Fix

Force-apply all props the first time a freshly created component view receives updateProps. An unchanged node is never dirty, so a new native view must apply everything once, matching RN core's mount behavior. Validated on iOS (the bug fully reproduces and this fully resolves it).

packages/nitrogen/src/views/swift/SwiftHybridViewManager.ts:

 @implementation ${component} {
   std::shared_ptr<${HybridTSpecSwift}> _hybridView;
+  bool _hasUpdatedProps;
 }
   // 2. Update each prop individually
   swiftPart.beforeUpdate();
+
+  // A freshly created view (e.g. one Fabric recreates when a
+  // react-native-screens screen re-attaches) must apply ALL props, not just
+  // the ones the shadow node marks dirty. An unchanged node is never dirty,
+  // so the new native view would otherwise never receive its props.
+  const bool forceUpdate = !_hasUpdatedProps;
+  _hasUpdatedProps = true;
-if (newViewProps.${name}.isDirty) {
+if (forceUpdate || newViewProps.${name}.isDirty) {
   swiftPart.${setter}(...);
   newViewProps.${name}.isDirty = false;
 }

Android has the same defect in the generated C++ updateViewProps (KotlinHybridViewManager.ts, same if (props->NAME.isDirty)). The fix needs a bit more plumbing there because updateViewProps is a JNI free function rather than a method on the view, so the "first update" flag has to live on the hybrid view instance (or the Kotlin ViewManager has to pass a freshly-created signal into the updater) instead of an Objective-C ivar.

Alternative considered

Conforming the view to RecyclableView does not fix it (the example's RecyclableTestView shows the same behavior): the recreated or pooled view still is not re-applied (isDirty == false), so it comes back with default props, or shows a stale value if the instance was reused for a different slot.

Nitro Modules Version

0.35.4

Nitrogen Version

0.35.4

Operating system

MacOS

Additional information

Metadata

Metadata

Assignees

No one assigned

    Labels

    nitrogenIssue is related to the code-generator "Nitrogen"

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions