Skip to content

Commit 43336dc

Browse files
j-piaseckifacebook-github-bot
authored andcommitted
Fix display: contents nodes having hasNewLayout set incorrectly (#1970)
Summary: X-link: react/react-native#57103 Changelog: [General][Fixed] Fixed `display: contents` nodes having `hasNewLayout` set incorrectly `cleanupContentsNodesRecursively` unconditionally sets `hasNewLayout=true` on `display: contents` children, including on code paths where their parent's layout was not actually performed in this pass. The stale flag can survive across layout passes and, in clone-on-write renderers (e.g. React Native Fabric), be observed by a subsequent pass whose parent was cloned but whose layout was served from cache, leaving the contents child's owner pointing at the previous parent revision. There are two paths through which the cleanup could stamp a contents child whose parent's `hasNewLayout` would end up false: 1. Measure-phase visit. Inside `calculateLayoutImpl`, the cleanup ran with no knowledge of `performLayout`. When the parent's `calculateLayoutImpl` was invoked only with `performLayout=false` (cache miss on measure, cache hit on layout), the cleanup stamped contents children even though the parent itself never had its `hasNewLayout` set. 2. Absolute-layout walk. `layoutAbsoluteDescendants` walks every static layout descendant of the containing block - including ones whose own `calculateLayoutImpl` was skipped via the layout-phase cache. The cleanup invoked along that walk unconditionally stamped contents children, but the parent's `hasNewLayout` was only updated when the recursion actually found new layout downstream. In both cases, the result is the same invariant violation: a contents node with `hasNewLayout=true` whose parent has `hasNewLayout=false`. A consumer iterating the tree via `hasNewLayout` skips the parent and never clears the stale flag. Test Plan: Added `YGContentsNodeHasNewLayoutTest.cpp` with regression tests: - `contents_child_hasNewLayout_not_stamped_on_measure_only_visit` - pins the measure-phase fix - `absolute_descendant_through_contents_is_reachable_via_hasNewLayout` - pins the positive case for absolute-layout path - `absolute_phase_cleanup_does_not_stamp_when_parent_layout_skipped` - pins the negative case for absolute-layout path Reviewed By: javache Differential Revision: D107854528 Pulled By: j-piasecki
1 parent 4a59d09 commit 43336dc

4 files changed

Lines changed: 204 additions & 9 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#include <gtest/gtest.h>
9+
#include <yoga/Yoga.h>
10+
11+
namespace facebook::yoga {
12+
13+
// Regression test for `cleanupContentsNodesRecursively` stamping
14+
// `hasNewLayout=true` on a display:contents child during a measurement-only
15+
// visit.
16+
//
17+
// Setup: Root (overflow=visible, flex column) -> Parent (flex-grow=1)
18+
// -> Contents (display:contents) -> Leaf.
19+
// Flipping root's overflow between the two passes invalidates Parent's
20+
// measurement cache (computeFlexBasisForChild's `applyHeightFitContent`
21+
// branch flips) but leaves Parent's layout cache intact (its allotment is
22+
// unchanged). So in pass 2, Parent's calculateLayoutImpl runs only with
23+
// performLayout=false - the layout-phase visit is served from cache and
24+
// `cleanupContentsNodesRecursively` never runs at performLayout=true.
25+
TEST(YogaTest, contents_child_hasNewLayout_not_stamped_on_measure_only_visit) {
26+
YGNodeRef leaf = YGNodeNew();
27+
YGNodeStyleSetWidth(leaf, 20);
28+
YGNodeStyleSetHeight(leaf, 20);
29+
30+
YGNodeRef contents = YGNodeNew();
31+
YGNodeStyleSetDisplay(contents, YGDisplayContents);
32+
YGNodeInsertChild(contents, leaf, 0);
33+
34+
YGNodeRef parent = YGNodeNew();
35+
YGNodeStyleSetFlexGrow(parent, 1);
36+
YGNodeInsertChild(parent, contents, 0);
37+
38+
YGNodeRef root = YGNodeNew();
39+
YGNodeStyleSetFlexDirection(root, YGFlexDirectionColumn);
40+
YGNodeStyleSetWidth(root, 200);
41+
YGNodeStyleSetHeight(root, 200);
42+
YGNodeStyleSetOverflow(root, YGOverflowVisible);
43+
YGNodeInsertChild(root, parent, 0);
44+
45+
YGNodeCalculateLayout(root, 200, 200, YGDirectionLTR);
46+
47+
// Simulate a consumer (e.g. React Native's layout pass) reading and
48+
// clearing the hasNewLayout flags.
49+
YGNodeSetHasNewLayout(root, false);
50+
YGNodeSetHasNewLayout(parent, false);
51+
YGNodeSetHasNewLayout(contents, false);
52+
YGNodeSetHasNewLayout(leaf, false);
53+
54+
YGNodeStyleSetOverflow(root, YGOverflowScroll);
55+
YGNodeCalculateLayout(root, 200, 200, YGDirectionLTR);
56+
57+
EXPECT_FALSE(YGNodeGetHasNewLayout(contents))
58+
<< "contents.hasNewLayout was stamped during a measure-only visit "
59+
"(cleanupContentsNodesRecursively ran with performLayout=false but "
60+
"no matching performLayout=true visit occurred this pass)";
61+
62+
YGNodeFreeRecursive(root);
63+
}
64+
65+
// Regression test for `cleanupContentsNodesRecursively` invoked from
66+
// `layoutAbsoluteDescendants`: it must stamp `hasNewLayout=true` on
67+
// display:contents children on the path to an absolute descendant whose
68+
// position changed this pass. Otherwise consumers traversing the tree via
69+
// hasNewLayout would skip the contents subtree and miss the update.
70+
//
71+
// Setup: root (containing block) -> staticChild (fixed 50x50)
72+
// -> contents (display:contents) -> absoluteChild (right/bottom-
73+
// anchored so its position depends on the containing block).
74+
// Growing root in pass 2 dirties only root. staticChild's fixed dimensions
75+
// make its layout cache hit, so its main-path cleanup never runs.
76+
// absoluteChild depends on the containing block and is repositioned by
77+
// `layoutAbsoluteDescendants`, which is the only path that can stamp
78+
// contents along the way.
79+
TEST(
80+
YogaTest,
81+
absolute_descendant_through_contents_is_reachable_via_hasNewLayout) {
82+
YGNodeRef absoluteChild = YGNodeNew();
83+
YGNodeStyleSetPositionType(absoluteChild, YGPositionTypeAbsolute);
84+
YGNodeStyleSetPosition(absoluteChild, YGEdgeRight, 0);
85+
YGNodeStyleSetPosition(absoluteChild, YGEdgeBottom, 0);
86+
YGNodeStyleSetWidth(absoluteChild, 10);
87+
YGNodeStyleSetHeight(absoluteChild, 10);
88+
89+
YGNodeRef contents = YGNodeNew();
90+
YGNodeStyleSetDisplay(contents, YGDisplayContents);
91+
YGNodeInsertChild(contents, absoluteChild, 0);
92+
93+
YGNodeRef staticChild = YGNodeNew();
94+
YGNodeStyleSetPositionType(staticChild, YGPositionTypeStatic);
95+
YGNodeStyleSetWidth(staticChild, 50);
96+
YGNodeStyleSetHeight(staticChild, 50);
97+
YGNodeInsertChild(staticChild, contents, 0);
98+
99+
YGNodeRef root = YGNodeNew();
100+
YGNodeStyleSetWidth(root, 100);
101+
YGNodeStyleSetHeight(root, 100);
102+
YGNodeInsertChild(root, staticChild, 0);
103+
104+
YGNodeCalculateLayout(root, 100, 100, YGDirectionLTR);
105+
106+
// Simulate a consumer (e.g. React Native's layout pass) reading and
107+
// clearing the hasNewLayout flags.
108+
YGNodeSetHasNewLayout(root, false);
109+
YGNodeSetHasNewLayout(staticChild, false);
110+
YGNodeSetHasNewLayout(contents, false);
111+
YGNodeSetHasNewLayout(absoluteChild, false);
112+
113+
YGNodeStyleSetWidth(root, 150);
114+
YGNodeCalculateLayout(root, 150, 100, YGDirectionLTR);
115+
116+
ASSERT_TRUE(YGNodeGetHasNewLayout(absoluteChild));
117+
EXPECT_TRUE(YGNodeGetHasNewLayout(staticChild));
118+
EXPECT_TRUE(YGNodeGetHasNewLayout(contents))
119+
<< "contents node on the path to a freshly-positioned absolute "
120+
"descendant must have hasNewLayout=true so consumers can traverse "
121+
"to it";
122+
123+
YGNodeFreeRecursive(root);
124+
}
125+
126+
// Regression test for `cleanupContentsNodesRecursively` invoked from
127+
// `layoutAbsoluteDescendants`: it must not stamp `hasNewLayout=true` on
128+
// display:contents children when no new layout was produced for their
129+
// parent this pass. Otherwise the stale flag survives across passes and
130+
// can be observed by a later cache-hit on the parent.
131+
//
132+
// Setup: root -> a (fixed 50x50) -> b (fixed 30x30)
133+
// -> contents (display:contents) -> leaf.
134+
// Flipping root's overflow in pass 2 dirties only root. a and b have fixed
135+
// sizes so their layout caches hit; a.calculateLayoutImpl is skipped, so
136+
// b.calculateLayoutInternal is never invoked. `layoutAbsoluteDescendants`
137+
// still walks down through a and b looking for absolute descendants, but
138+
// there are none beneath b - so b.hasNewLayout stays false and the
139+
// cleanup along that walk must leave contents unflagged.
140+
TEST(
141+
YogaTest,
142+
absolute_phase_cleanup_does_not_stamp_when_parent_layout_skipped) {
143+
YGNodeRef leaf = YGNodeNew();
144+
YGNodeStyleSetWidth(leaf, 10);
145+
YGNodeStyleSetHeight(leaf, 10);
146+
147+
YGNodeRef contents = YGNodeNew();
148+
YGNodeStyleSetDisplay(contents, YGDisplayContents);
149+
YGNodeInsertChild(contents, leaf, 0);
150+
151+
YGNodeRef b = YGNodeNew();
152+
YGNodeStyleSetPositionType(b, YGPositionTypeStatic);
153+
YGNodeStyleSetWidth(b, 30);
154+
YGNodeStyleSetHeight(b, 30);
155+
YGNodeInsertChild(b, contents, 0);
156+
157+
YGNodeRef a = YGNodeNew();
158+
YGNodeStyleSetPositionType(a, YGPositionTypeStatic);
159+
YGNodeStyleSetWidth(a, 50);
160+
YGNodeStyleSetHeight(a, 50);
161+
YGNodeInsertChild(a, b, 0);
162+
163+
YGNodeRef root = YGNodeNew();
164+
YGNodeStyleSetWidth(root, 200);
165+
YGNodeStyleSetHeight(root, 200);
166+
YGNodeStyleSetOverflow(root, YGOverflowVisible);
167+
YGNodeInsertChild(root, a, 0);
168+
169+
YGNodeCalculateLayout(root, 200, 200, YGDirectionLTR);
170+
171+
// Simulate a consumer (e.g. React Native's layout pass) reading and
172+
// clearing the hasNewLayout flags.
173+
YGNodeSetHasNewLayout(root, false);
174+
YGNodeSetHasNewLayout(a, false);
175+
YGNodeSetHasNewLayout(b, false);
176+
YGNodeSetHasNewLayout(contents, false);
177+
YGNodeSetHasNewLayout(leaf, false);
178+
179+
YGNodeStyleSetOverflow(root, YGOverflowScroll);
180+
YGNodeCalculateLayout(root, 200, 200, YGDirectionLTR);
181+
182+
EXPECT_FALSE(YGNodeGetHasNewLayout(b));
183+
EXPECT_FALSE(YGNodeGetHasNewLayout(contents))
184+
<< "contents.hasNewLayout was stamped during a walk where its "
185+
"parent's hasNewLayout remained false this pass";
186+
187+
YGNodeFreeRecursive(root);
188+
}
189+
190+
} // namespace facebook::yoga

yoga/algorithm/AbsoluteLayout.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,6 @@ bool layoutAbsoluteDescendants(
558558
// we need to mutate these descendents. Make sure the path of
559559
// nodes to them is mutable before positioning.
560560
child->cloneChildrenIfNeeded();
561-
cleanupContentsNodesRecursively(child);
562561
const Direction childDirection =
563562
child->resolveDirection(currentNodeDirection);
564563
// By now all descendants of the containing block that are not absolute
@@ -584,6 +583,8 @@ bool layoutAbsoluteDescendants(
584583
containingNodeAvailableInnerHeight) ||
585584
hasNewLayout;
586585

586+
cleanupContentsNodesRecursively(
587+
child, /* didPerformLayout */ hasNewLayout);
587588
if (hasNewLayout) {
588589
child->setHasNewLayout(hasNewLayout);
589590
}

yoga/algorithm/CalculateLayout.cpp

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -516,19 +516,23 @@ void zeroOutLayoutRecursively(yoga::Node* const node) {
516516
}
517517
}
518518

519-
void cleanupContentsNodesRecursively(yoga::Node* const node) {
519+
void cleanupContentsNodesRecursively(
520+
yoga::Node* const node,
521+
bool didPerformLayout) {
520522
if (node->hasContentsChildren()) [[unlikely]] {
521523
node->cloneContentsChildrenIfNeeded();
522524
for (auto child : node->getChildren()) {
523525
if (child->style().display() == Display::Contents) {
524526
child->getLayout() = {};
525527
child->setLayoutDimension(0, Dimension::Width);
526528
child->setLayoutDimension(0, Dimension::Height);
527-
child->setHasNewLayout(true);
529+
if (didPerformLayout) {
530+
child->setHasNewLayout(true);
531+
}
528532
child->setDirty(false);
529533
child->cloneChildrenIfNeeded();
530534

531-
cleanupContentsNodesRecursively(child);
535+
cleanupContentsNodesRecursively(child, didPerformLayout);
532536
}
533537
}
534538
}
@@ -1617,7 +1621,7 @@ static void calculateLayoutImpl(
16171621

16181622
// Clean and update all display: contents nodes with a direct path to the
16191623
// current node as they will not be traversed
1620-
cleanupContentsNodesRecursively(node);
1624+
cleanupContentsNodesRecursively(node, performLayout);
16211625
return;
16221626
}
16231627

@@ -1635,7 +1639,7 @@ static void calculateLayoutImpl(
16351639

16361640
// Clean and update all display: contents nodes with a direct path to the
16371641
// current node as they will not be traversed
1638-
cleanupContentsNodesRecursively(node);
1642+
cleanupContentsNodesRecursively(node, performLayout);
16391643
return;
16401644
}
16411645

@@ -1653,7 +1657,7 @@ static void calculateLayoutImpl(
16531657
ownerHeight)) {
16541658
// Clean and update all display: contents nodes with a direct path to the
16551659
// current node as they will not be traversed
1656-
cleanupContentsNodesRecursively(node);
1660+
cleanupContentsNodesRecursively(node, /* didPerformLayout */ false);
16571661
return;
16581662
}
16591663

@@ -1665,7 +1669,7 @@ static void calculateLayoutImpl(
16651669

16661670
// Clean and update all display: contents nodes with a direct path to the
16671671
// current node as they will not be traversed
1668-
cleanupContentsNodesRecursively(node);
1672+
cleanupContentsNodesRecursively(node, performLayout);
16691673

16701674
// STEP 1: CALCULATE VALUES FOR REMAINDER OF ALGORITHM
16711675
const FlexDirection mainAxis =

yoga/algorithm/CalculateLayout.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,6 @@ float calculateAvailableInnerDimension(
5555

5656
void zeroOutLayoutRecursively(yoga::Node* const node);
5757

58-
void cleanupContentsNodesRecursively(yoga::Node* const node);
58+
void cleanupContentsNodesRecursively(yoga::Node* node, bool didPerformLayout);
5959

6060
} // namespace facebook::yoga

0 commit comments

Comments
 (0)