Provide a way to avoid/solve React Native view flattening issues
Describe the feature request
This is a follow-up to #318 regarding the nativeProps feature request, in which @necolas raised a specific issue related to view flattening and the collapsable prop.
Brief Summary
- By default, React Native performs view flattening on both iOS and Android. This process removes "layout-only" views from the platform view tree to reduce the number of platform views created. In contrast, the web does not have a corresponding technique, as the DOM element tree is preserved as rendered.
- Layout-only views are views that handle layout-related properties like margins, padding, etc., which can be managed by Yoga and safely removed. However, views with properties like background color, opacity, borders, shadows, etc., cannot be flattened because these properties cannot be replaced by pure Yoga layout adjustments.
- View flattening distorts the platform view tree, both statically (by making views that were previously separate into siblings) and dynamically (when a view, along with all its children, transitions between being "layout-only" and an actual view).
Example of issues caused by view flattening
- Screen reader focus order: On iOS, the screen reader considers platform view grouping, reading text elements under the same parent view consecutively. With view flattening, views from different flattened containers can end up in the same container, resulting in an incorrect screen reader order (this is the issue I am currently facing).
- Validation error styling: For example, setting a red border or background around a view containing a text input to signal a validation error can cause the container view to change from "layout-only" to an actual view. This, in turn, can trigger the platform text input component to be recreated, resetting its intrinsic native state (e.g., cursor position). More details here.
- Community libraries: Libraries like React Native Gesture Handler sometimes need to attach to an actual native view. With view flattening, the required view might be removed, causing errors.
These are just a few examples of how view flattening can lead to unexpected and hard-to-diagnose issues, as it’s not always obvious which views are being removed.
Traditionally, the standard way to disable view flattening is by using the collapsable prop, which explicitly clarifies the developer's intent. However, this prop is not accessible in RSD. The proposal for nativeProps was intended to create an escape hatch to allow access to this or other similar props.
Potential solutions
- Disable view flattening in RSD altogether: This would align React Native's behavior with the DOM, where platform views are never flattened. However, this is a crude solution that would eliminate a generally useful optimization technique and could have performance implications.
- Encourage users to use React Native's
Viewinstead ofh.div, etc.: While possible, this approach requires users to maintain two sets of styles, RSD styles and React Native styles, which is a significant inconvenience. - Use another prop that disables view flattening as a side effect: For example, the
nativeIDprop (which RSD maps fromid) could be used. However, this approach lacks clarity of intent, and a future developer (or even our future selves) might mistakenly remove it, thinking the ID is unused.
Web have display: contents that mighty be helpful.
@MoOx, good catch that the web actually has something similar to view flattening. However, the key difference is that, on the web, this is an opt-in feature, whereas in React Native, view flattening it is opt-out. React Native also supports display: contents directly.
That being said, I don’t see how display: contents would help with the default view flattening issues in React Native 🤷♂️
Thanks for this clear and thoughtful description of the problem and potential solutions.
Disable view flattening in RSD altogether
I think this makes the most sense, although it is sure to raise some concerns about potential performance drawbacks. However, this would be a reliable way to avoid the issues associated with view flattening, especially if it is not sophisticated enough to account for things like screenreader order. Furthermore, I think there are only going to be more cases in the future where unexpected flattening on native is going to cause divergence from what is expected in existing web code/patterns, e.g., walking the DOM tree, reading event paths.
I spoke with @rozele about this subject and we're going to look into a couple of things:
- I'll test disabling view flattening altogether in RSD internally, and work with our perf gurus to understand the impact.
- Eric thinks we can look into this and may have ways to address these issues from within RN, without RSD having to throw view flattening out altogether.
You might be interested in this piece of code that determines whether a stacking context is created: https://github.com/facebook/react-native/blob/fd29d68c162261b35bbfa25f4185e24ef5f4f5cb/packages/react-native/ReactCommon/react/renderer/components/view/ViewShadowNode.cpp#L49
Are there any specific accessibility props that we could add here which would fix the "Screen reader focus order" issue? Or is that an issue that can appear for Views that don't/shouldn't have accessibility props?
It also looks another way to disable view flattening (for now) is with certain CSS properties, like isolation:isolate. That will apply to web too though, but at least it will result in consistent layout across platforms.
I've prepared contrived example to showcase the issue:
The code
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>Default (View Flattening)</Text>
<View style={styles.item}>
<View style={styles.itemContent}>
<Text>Title 1</Text>
<Text>Description 1</Text>
</View>
<Pressable onPress={() => {}}>
<Text>Action 1</Text>
</Pressable>
</View>
<Text style={styles.sectionTitle}>Disabled view flattening</Text>
<View style={styles.item}>
<!-- here is the only difference: disabled `collapsable` prop -->
<View style={styles.itemContent} collapsable={false}>
<Text>Title 2</Text>
<Text>Description 2</Text>
</View>
<Pressable onPress={() => {}}>
<Text>Action 2</Text>
</Pressable>
</View>
</View>
);
const styles = StyleSheet.create({
item: {
padding: 16,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
itemContent: {
flex: 1,
},
});
The effect (on iOS)
https://github.com/user-attachments/assets/513fbbbf-b36a-4fd2-b4a5-b5d1989189d7
-
In the first group (with default view flattening), the focus order is: Title 1 => Action 1 => Description 1.
-
However, if I disable view flattening on the view with style={styles.itemContent}, the focus order becomes the expected: Title 2 => Description 2 => Action 2.
A runnable repro is available here.
Considerations
- In my case, I am using plain
ViewandTextcomponents with default props. No accessibility props are set. - If React Native were to somehow handle this case, it could keep track of "readable" elements inside a container (e.g.,
Textcomponents, elements withaccessibilityLabel, etc.). If a container contains 2+ readable elements, it should not be marked as layout-only. - A more advanced solution could also account for
flexDirectionchanges. For example, in my case, a flattened container with a vertical layout is placed inside a container with a horizontal layout. However, this raises questions about other layout cases, such as absolute positioning.
~I ran my Fantom benchmark with and without collapsable: false on View, and there was no significant difference in the results.~
TIL Fantom can't surface the perf benefits of view flattening as it doesn't really create native views.
collapsable: (not set)
| (index) | Task name | Latency average (ns) | Latency median (ns) | Throughput average (ops/s) | Throughput median (ops/s) | Samples |
|---|---|---|---|---|---|---|
| 0 | '250 nested <html.div> with props and styles' | '72665988.86 ± 0.54%' | '72641718.00 ± 62929.00' | '14 ± 0.53%' | '14' | 64 |
| 1 | '250 sibling <html.div> with props and styles' | '73327540.83 ± 0.38%' | '73108708.50 ± 3770.50' | '14 ± 0.37%' | '14' | 64 |
collapsable: false
| (index) | Task name | Latency average (ns) | Latency median (ns) | Throughput average (ops/s) | Throughput median (ops/s) | Samples |
|---|---|---|---|---|---|---|
| 0 | '250 nested <html.div> with props and styles' | '72421952.50 ± 0.52%' | '72208818.00 ± 22138.00' | '14 ± 0.51%' | '14' | 64 |
| 1 | '250 sibling <html.div> with props and styles' | '71698233.72 ± 0.35%' | '71766104.00 ± 20395.00' | '14 ± 0.35%' | '14' | 64 |
@necolas to double check, are the tests you are using for perf comparison use styles that are "layout-only" and would trigger View Flattening?
In this case the tests have a lot of different styles, some of which are preventing view flattening. I later created some other tests that did show a performance difference in Fantom when collapsable={false}, so I think we can surface some of the perf benefit of view flattening via Fantom tests.
View flattening is cool and weird at the same time. I'm writing maestro tests and need to rely on the structure. For example I target a text. Yet my assertVisible fails. When I look at the source I have ", Currency, USD, "
- assertVisible:
text: 'Currency'
In my react code I'm placing the text currency inside of a <Text/> component. Yet in the final view Currency is not sitting alone in a RCTText, but somehow it is there with an icon, currency, usd and another icon.
I was more expecting this layout
🤷 It feels like I'm playing a slot machine.