Skip to content

Commit 1dfefdd

Browse files
sammy-SCfacebook-github-bot
authored andcommitted
Fix flex-growing content sizing in <ScrollView> (#57601)
Summary: X-link: react/yoga#1997 Preserve available height for flex-growing subtrees nested inside scroll content. Apply optimized flex-basis measurement only when a conservative proof shows that the subtree is height-independent and connected to the nearest scroll ancestor through column-stretch layout edges. Keep authored flex-basis, overflow, padding, absolute-positioning, wrapping, percentage-owner, and box-sizing semantics intact across layout and measurement passes. Gate `boxSizing` cache invalidation with the same Yoga feature so disabling it preserves legacy caching behavior. Add Fantom coverage for native `boxSizing` updates and for rerendering after both `flexBasis` and intrinsic height change. Verify the expected behavior across both `fixYogaFlexBasisFitContentInMainAxis` states. Changelog: [internal] Reviewed By: rubennorte Differential Revision: D112353599
1 parent 9efcdfc commit 1dfefdd

2 files changed

Lines changed: 561 additions & 75 deletions

File tree

packages/react-native/Libraries/Components/View/__tests__/View-flexBasisFitContent-itest.js

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,360 @@ test('auto-height wrapper around a feed ScrollView stays bounded by the viewport
6565
expect(feedContentRect.height).toBe(FEED_CONTENT_HEIGHT);
6666
});
6767

68+
test('updates flex-basis and intrinsic height in ScrollView content', () => {
69+
const root = Fantom.createRoot();
70+
const candidateRef = createRef<HostInstance>();
71+
72+
const render = (flexBasis: number, intrinsicHeight: number) => {
73+
Fantom.runTask(() => {
74+
root.render(
75+
<ScrollView>
76+
<View ref={candidateRef} style={{flexBasis}}>
77+
<View style={{height: intrinsicHeight}} />
78+
</View>
79+
</ScrollView>,
80+
);
81+
});
82+
return nullthrows(candidateRef.current).getBoundingClientRect().height;
83+
};
84+
85+
expect(render(40, 40)).toBe(40);
86+
87+
expect(render(80, 20)).toBe(20);
88+
});
89+
90+
test('auto-height wrapper gives remaining ScrollView space to a flex-growing body', () => {
91+
const viewportHeight = 200;
92+
const headerHeight = 50;
93+
const root = Fantom.createRoot({
94+
viewportHeight,
95+
viewportWidth: 100,
96+
});
97+
98+
const wrapperRef = createRef<HostInstance>();
99+
const growingBodyRef = createRef<HostInstance>();
100+
101+
Fantom.runTask(() => {
102+
root.render(
103+
<ScrollView
104+
contentContainerStyle={{alignItems: 'flex-start', flexGrow: 1}}>
105+
<View collapsable={false} ref={wrapperRef}>
106+
<View collapsable={false} style={{height: headerHeight, width: 50}} />
107+
<View
108+
collapsable={false}
109+
ref={growingBodyRef}
110+
style={{flexGrow: 1, height: 40, width: 50}}
111+
/>
112+
</View>
113+
</ScrollView>,
114+
);
115+
});
116+
117+
const wrapperRect = nullthrows(wrapperRef.current).getBoundingClientRect();
118+
const growingBodyRect = nullthrows(
119+
growingBodyRef.current,
120+
).getBoundingClientRect();
121+
122+
const layout = {
123+
growingBodyHeight: growingBodyRect.height,
124+
wrapperHeight: wrapperRect.height,
125+
};
126+
127+
expect(layout).toEqual({
128+
growingBodyHeight: viewportHeight - headerHeight,
129+
wrapperHeight: viewportHeight,
130+
});
131+
});
132+
133+
test('positive flex-basis preserves content height in an unbounded ScrollView axis', () => {
134+
const root = Fantom.createRoot({
135+
viewportHeight: 200,
136+
viewportWidth: 100,
137+
});
138+
139+
const wrapperRef = createRef<HostInstance>();
140+
const contentRef = createRef<HostInstance>();
141+
142+
Fantom.runTask(() => {
143+
root.render(
144+
<ScrollView>
145+
<View collapsable={false} ref={wrapperRef} style={{flexBasis: 50}}>
146+
<View collapsable={false} ref={contentRef} style={{height: 100}} />
147+
</View>
148+
</ScrollView>,
149+
);
150+
});
151+
152+
expect({
153+
contentHeight: nullthrows(contentRef.current).getBoundingClientRect()
154+
.height,
155+
wrapperHeight: nullthrows(wrapperRef.current).getBoundingClientRect()
156+
.height,
157+
}).toEqual({
158+
contentHeight: 100,
159+
wrapperHeight: 100,
160+
});
161+
});
162+
163+
test('positive flex-basis larger than content preserves content height in an unbounded ScrollView axis', () => {
164+
const root = Fantom.createRoot({
165+
viewportHeight: 200,
166+
viewportWidth: 100,
167+
});
168+
169+
const wrapperRef = createRef<HostInstance>();
170+
const contentRef = createRef<HostInstance>();
171+
172+
Fantom.runTask(() => {
173+
root.render(
174+
<ScrollView>
175+
<View collapsable={false} ref={wrapperRef} style={{flexBasis: 100}}>
176+
<View collapsable={false} ref={contentRef} style={{height: 40}} />
177+
</View>
178+
</ScrollView>,
179+
);
180+
});
181+
182+
const layout = {
183+
contentHeight: nullthrows(contentRef.current).getBoundingClientRect()
184+
.height,
185+
wrapperHeight: nullthrows(wrapperRef.current).getBoundingClientRect()
186+
.height,
187+
};
188+
189+
expect(layout).toEqual({
190+
contentHeight: 40,
191+
wrapperHeight: 40,
192+
});
193+
});
194+
195+
test('nested padding respects an exhausted ScrollView height constraint', () => {
196+
const viewportHeight = 100;
197+
const root = Fantom.createRoot({
198+
viewportHeight,
199+
viewportWidth: 100,
200+
});
201+
202+
const wrapperRef = createRef<HostInstance>();
203+
const nestedRef = createRef<HostInstance>();
204+
const paddedRef = createRef<HostInstance>();
205+
206+
Fantom.runTask(() => {
207+
root.render(
208+
<ScrollView contentContainerStyle={{height: viewportHeight}}>
209+
<View
210+
collapsable={false}
211+
ref={wrapperRef}
212+
style={{paddingBottom: 50, paddingTop: 50}}>
213+
<View collapsable={false} ref={nestedRef}>
214+
<View
215+
collapsable={false}
216+
ref={paddedRef}
217+
style={{paddingBottom: 10, paddingTop: 10}}
218+
/>
219+
</View>
220+
</View>
221+
</ScrollView>,
222+
);
223+
});
224+
225+
expect({
226+
nestedHeight: nullthrows(nestedRef.current).getBoundingClientRect().height,
227+
paddedHeight: nullthrows(paddedRef.current).getBoundingClientRect().height,
228+
wrapperHeight: nullthrows(wrapperRef.current).getBoundingClientRect()
229+
.height,
230+
}).toEqual({
231+
nestedHeight: 0,
232+
paddedHeight: 20,
233+
wrapperHeight: viewportHeight,
234+
});
235+
});
236+
237+
test('clipped ScrollView inside a horizontal pager stays scrollable', () => {
238+
const root = Fantom.createRoot({
239+
viewportHeight: 150,
240+
viewportWidth: 320,
241+
});
242+
243+
const outerScrollRef = createRef<HostInstance>();
244+
const boundedContainerRef = createRef<HostInstance>();
245+
const autoHeightWrapperRef = createRef<HostInstance>();
246+
const innerScrollRef = createRef<HostInstance>();
247+
248+
Fantom.runTask(() => {
249+
root.render(
250+
<ScrollView horizontal pagingEnabled ref={outerScrollRef}>
251+
<View
252+
collapsable={false}
253+
ref={boundedContainerRef}
254+
style={{height: 150, overflow: 'hidden', width: 320}}>
255+
<View collapsable={false} ref={autoHeightWrapperRef}>
256+
<ScrollView ref={innerScrollRef}>
257+
<View style={{height: 240}} />
258+
</ScrollView>
259+
</View>
260+
</View>
261+
<View style={{height: 150, width: 320}} />
262+
</ScrollView>,
263+
);
264+
});
265+
266+
const outerScroll = nullthrows(outerScrollRef.current);
267+
const innerScroll = nullthrows(innerScrollRef.current);
268+
const layout = {
269+
autoHeightWrapperHeight: nullthrows(
270+
autoHeightWrapperRef.current,
271+
).getBoundingClientRect().height,
272+
boundedContainerHeight: nullthrows(
273+
boundedContainerRef.current,
274+
).getBoundingClientRect().height,
275+
innerContentHeight: innerScroll.scrollHeight,
276+
innerHeight: innerScroll.getBoundingClientRect().height,
277+
innerScrollRange: innerScroll.scrollHeight - innerScroll.clientHeight,
278+
outerHorizontalScrollRange:
279+
outerScroll.scrollWidth - outerScroll.clientWidth,
280+
outerVerticalScrollRange:
281+
outerScroll.scrollHeight - outerScroll.clientHeight,
282+
};
283+
284+
expect(layout).toEqual({
285+
autoHeightWrapperHeight: 150,
286+
boundedContainerHeight: 150,
287+
innerContentHeight: 240,
288+
innerHeight: 150,
289+
innerScrollRange: 90,
290+
outerHorizontalScrollRange: 320,
291+
outerVerticalScrollRange: 0,
292+
});
293+
});
294+
295+
test('percentage padding tracks an updated owner width', () => {
296+
const root = Fantom.createRoot({
297+
viewportHeight: 300,
298+
viewportWidth: 300,
299+
});
300+
301+
function render(updated: boolean) {
302+
Fantom.runTask(() => {
303+
root.render(
304+
<View
305+
collapsable={false}
306+
style={{
307+
height: 200,
308+
overflow: 'scroll',
309+
width: updated ? 200 : 100,
310+
}}>
311+
<View collapsable={false} style={updated ? {height: 150} : undefined}>
312+
<View
313+
collapsable={false}
314+
nativeID="candidate"
315+
style={{maxWidth: 50, paddingLeft: '25%'}}
316+
/>
317+
</View>
318+
</View>,
319+
);
320+
});
321+
}
322+
323+
render(false);
324+
render(true);
325+
326+
expect(
327+
root
328+
.getRenderedOutput({
329+
includeLayoutMetrics: true,
330+
props: ['layoutMetrics-contentInsets', 'nativeID'],
331+
})
332+
.toJSX(),
333+
).toEqual(
334+
<rn-view layoutMetrics-contentInsets="{top:0,right:0,bottom:0,left:0}">
335+
<rn-view layoutMetrics-contentInsets="{top:0,right:0,bottom:0,left:0}">
336+
<rn-view
337+
layoutMetrics-contentInsets="{top:0,right:0,bottom:0,left:50}"
338+
nativeID="candidate"
339+
/>
340+
</rn-view>
341+
</rn-view>,
342+
);
343+
});
344+
345+
test('gates native box sizing cache invalidation', () => {
346+
const root = Fantom.createRoot({
347+
viewportHeight: 280,
348+
viewportWidth: 240,
349+
});
350+
const scrollRef = createRef<HostInstance>();
351+
const leafRef = createRef<HostInstance>();
352+
353+
const render = (siblingHeight: number) => {
354+
Fantom.runTask(() => {
355+
root.render(
356+
<View
357+
collapsable={false}
358+
style={{
359+
alignItems: 'flex-start',
360+
flexDirection: 'row',
361+
height: 240,
362+
width: 181,
363+
}}>
364+
<View
365+
collapsable={false}
366+
style={{alignItems: 'flex-start', alignSelf: 'flex-start'}}>
367+
<ScrollView ref={scrollRef} style={{height: 200}}>
368+
<View collapsable={false} style={{height: 100}}>
369+
<View
370+
collapsable={false}
371+
style={{flexBasis: 100, flexShrink: 1}}>
372+
<View collapsable={false}>
373+
<View
374+
collapsable={false}
375+
ref={leafRef}
376+
style={{
377+
borderLeftWidth: 1,
378+
borderRightWidth: 2,
379+
paddingHorizontal: 3,
380+
width: 83,
381+
}}
382+
/>
383+
</View>
384+
</View>
385+
<View style={{height: siblingHeight, width: 17}} />
386+
</View>
387+
</ScrollView>
388+
</View>
389+
</View>,
390+
);
391+
});
392+
};
393+
394+
render(27);
395+
Fantom.runTask(() => {
396+
nullthrows(leafRef.current).setNativeProps({
397+
style: {boxSizing: 'content-box'},
398+
});
399+
});
400+
render(40);
401+
402+
const layoutAfterFirstRerender = {
403+
leafWidth: nullthrows(leafRef.current).getBoundingClientRect().width,
404+
scrollWidth: nullthrows(scrollRef.current).getBoundingClientRect().width,
405+
};
406+
expect(layoutAfterFirstRerender).toEqual({
407+
leafWidth: 83,
408+
scrollWidth: 83,
409+
});
410+
411+
render(53);
412+
413+
expect({
414+
leafWidth: nullthrows(leafRef.current).getBoundingClientRect().width,
415+
scrollWidth: nullthrows(scrollRef.current).getBoundingClientRect().width,
416+
}).toEqual({
417+
leafWidth: 92,
418+
scrollWidth: 92,
419+
});
420+
});
421+
68422
const styles = StyleSheet.create({
69423
feed: {
70424
width: VIEWPORT_WIDTH,

0 commit comments

Comments
 (0)