Skip to content

Commit 44e177e

Browse files
expo-tuft[bot]ide
authored andcommitted
vendor: CSS Grid layout algorithm from Yoga PR react#1894 (unlanded upstream)
Grid lanes (css-grid-3) is defined on top of Grid's track sizing algorithm: it imports <track-size>, minmax(), fr, repeat(), auto-fill/auto-fit and line placement from css-grid-2 by name. That algorithm does not exist in our tree — PR 1/9 of the upstream series landed the style types (GridTrackSize, GridLine, gridTemplateColumns/Rows) but not the algorithm behind them. Rather than write a second one-axis track sizer, vendor the upstream implementation verbatim: react/yoga#1894 "CSS Grid 2/9: Grid layout algorithm" by Nishan Bende (intergalacticspacehighway), open since 2026-02-25 algorithm/grid/AutoPlacement.h css-grid-1 §8.5 auto-placement algorithm/grid/GridLayout.{h,cpp} entry point, implicit tracks algorithm/grid/GridTrack.h per-track layout state algorithm/grid/TrackSizing.h css-grid-2 §12 track sizing TrackSizing is parameterized by Dimension — it runs per axis — which is exactly the shape grid lanes needs, since lanes sizes tracks in the grid axis only and flows content in the other. Not our code: nothing in algorithm/grid/ is modified, so that directory can be deleted wholesale and replaced when the upstream series lands instead of being merged. Only the integration points are ours, and only because the PR could not supply them — our CalculateLayout.cpp has diverged (block formatting contexts) and our Yoga is 197 commits behind: - CalculateLayout.cpp: the Display::Grid dispatch, next to Display::Block - yoga/CMakeLists.txt: an explicit algorithm/grid/*.cpp glob, since CMake's ** matches one level; Android builds through this same file and iOS globs recursively via the podspec The PR's YGNodeStyle.cpp hunk is deliberately omitted: it adds a fallback return to silence GCC -Wreturn-type, and our tree already handles those switches better with fatalWithMessage("Unknown YGGridTrackType"). Verified, not assumed: verify-grid-vendor.sh builds yogacore with the vendored directory and lays out display: grid; width: 600px; gap: 10px; grid-template-columns: 100px 1fr 2fr; grid-template-rows: 50px auto; comparing every coordinate against the same CSS rendered by real Safari (grid-vendor-verify.html, read via safaridriver). 25/25 values match exactly, including fr distribution (160/320 of 480 remaining) and the auto row resolving to its tallest item. Upstream status: react#1894 is being re-split after review — react#1923 (data structures) and react#1994 (auto-placement) are open, but the track-sizing split has not been posted, which is why this vendors the original PR. See algorithm/grid/README-VENDORED.md. A third local fix, listed in README-VENDORED.md: the fit-content clamp in the inline axis was never applied, so a grid measured under a fit-content width reported its max-content width. It is now min(max-content, max(min-content, available)), its tracks sized again at the available width when the max-content exceeds it. A fourth: three steps of the track sizing gave an `auto` minimum an item's limited min-content contribution whenever Yoga's sizing mode was `MaxContent`, which is also how Yoga lays out an `auto` block size; that size is indefinite free space, not a max-content constraint, so a `0fr` row over an item with `min-height: 0` (the accordion idiom) stood as tall as the item's content. Only the inline axis is measured under a real max-content constraint now. `GridZeroFrRow-itest.js` pins it. Co-authored-by: ide <379606+ide@users.noreply.github.com>
1 parent e1aed07 commit 44e177e

14 files changed

Lines changed: 3756 additions & 8 deletions

File tree

‎grid-vendor-verify.cpp‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Smoke test for the vendored CSS Grid algorithm (Yoga PR #1894), pinned
2+
// against real Safari for the identical layout (see /tmp/gl/grid.html).
3+
//
4+
// display: grid; width: 600px; gap: 10px;
5+
// grid-template-columns: 100px 1fr 2fr;
6+
// grid-template-rows: 50px auto;
7+
// six children with fixed heights 20/30/25/40/15/35
8+
#include <yoga/Yoga.h>
9+
#include <cstdio>
10+
#include <cstdlib>
11+
12+
struct Expected {
13+
const char* name;
14+
float x, y, w, h;
15+
};
16+
17+
// Ground truth read out of Safari 26 via safaridriver.
18+
static const Expected kSafari[] = {
19+
{"a", 0.0f, 0.0f, 100.0f, 20.0f},
20+
{"b", 110.0f, 0.0f, 160.0f, 30.0f},
21+
{"c", 280.0f, 0.0f, 320.0f, 25.0f},
22+
{"d", 0.0f, 60.0f, 100.0f, 40.0f},
23+
{"e", 110.0f, 60.0f, 160.0f, 15.0f},
24+
{"f", 280.0f, 60.0f, 320.0f, 35.0f},
25+
};
26+
static const float kSafariContainerHeight = 100.0f;
27+
28+
int main() {
29+
const float heights[] = {20.0f, 30.0f, 25.0f, 40.0f, 15.0f, 35.0f};
30+
31+
YGNodeRef root = YGNodeNew();
32+
YGNodeStyleSetDisplay(root, YGDisplayGrid);
33+
YGNodeStyleSetWidth(root, 600.0f);
34+
YGNodeStyleSetGap(root, YGGutterAll, 10.0f);
35+
36+
YGNodeStyleSetGridTemplateColumnsCount(root, 3);
37+
YGNodeStyleSetGridTemplateColumn(root, 0, YGGridTrackTypePoints, 100.0f);
38+
YGNodeStyleSetGridTemplateColumn(root, 1, YGGridTrackTypeFr, 1.0f);
39+
YGNodeStyleSetGridTemplateColumn(root, 2, YGGridTrackTypeFr, 2.0f);
40+
41+
YGNodeStyleSetGridTemplateRowsCount(root, 2);
42+
YGNodeStyleSetGridTemplateRow(root, 0, YGGridTrackTypePoints, 50.0f);
43+
YGNodeStyleSetGridTemplateRow(root, 1, YGGridTrackTypeAuto, 0.0f);
44+
45+
for (size_t i = 0; i < 6; i++) {
46+
YGNodeRef child = YGNodeNew();
47+
YGNodeStyleSetHeight(child, heights[i]);
48+
YGNodeInsertChild(root, child, i);
49+
}
50+
51+
YGNodeCalculateLayout(root, YGUndefined, YGUndefined, YGDirectionLTR);
52+
53+
int failures = 0;
54+
auto check = [&](const char* what, float actual, float expected) {
55+
const bool ok = std::abs(actual - expected) < 0.01f;
56+
if (!ok) {
57+
failures++;
58+
}
59+
std::printf(
60+
" %-4s %-2s %7.2f %s %7.2f\n",
61+
what,
62+
ok ? "ok" : "XX",
63+
actual,
64+
ok ? "==" : "!=",
65+
expected);
66+
};
67+
68+
std::printf("container height:\n");
69+
check("h", YGNodeLayoutGetHeight(root), kSafariContainerHeight);
70+
71+
for (size_t i = 0; i < 6; i++) {
72+
const YGNodeRef child = YGNodeGetChild(root, i);
73+
const Expected& e = kSafari[i];
74+
std::printf("item %s:\n", e.name);
75+
check("x", YGNodeLayoutGetLeft(child), e.x);
76+
check("y", YGNodeLayoutGetTop(child), e.y);
77+
check("w", YGNodeLayoutGetWidth(child), e.w);
78+
check("h", YGNodeLayoutGetHeight(child), e.h);
79+
}
80+
81+
YGNodeFreeRecursive(root);
82+
std::printf(
83+
"\n%s (%d mismatches vs Safari)\n",
84+
failures == 0 ? "PASS" : "FAIL",
85+
failures);
86+
return failures == 0 ? 0 : 1;
87+
}

‎grid-vendor-verify.html‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!DOCTYPE html>
2+
<html><head><meta charset="utf-8"><style>
3+
* { margin: 0; padding: 0; box-sizing: border-box; }
4+
body { margin: 0; }
5+
#g {
6+
display: grid;
7+
width: 600px;
8+
grid-template-columns: 100px 1fr 2fr;
9+
grid-template-rows: 50px auto;
10+
gap: 10px;
11+
}
12+
#g > div { background: #cde; }
13+
</style></head>
14+
<body>
15+
<div id="g">
16+
<div id="a" style="height:20px"></div>
17+
<div id="b" style="height:30px"></div>
18+
<div id="c" style="height:25px"></div>
19+
<div id="d" style="height:40px"></div>
20+
<div id="e" style="height:15px"></div>
21+
<div id="f" style="height:35px"></div>
22+
</div>
23+
<script>
24+
window.__rects = (function () {
25+
const out = {};
26+
const g = document.getElementById('g');
27+
const gr = g.getBoundingClientRect();
28+
out.container = {w: gr.width, h: gr.height};
29+
for (const id of ['a','b','c','d','e','f']) {
30+
const r = document.getElementById(id).getBoundingClientRect();
31+
out[id] = {x: r.left - gr.left, y: r.top - gr.top, w: r.width, h: r.height};
32+
}
33+
return out;
34+
})();
35+
</script>
36+
</body></html>
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @flow strict-local
3+
* @format
4+
* @fantom_flags enableYogaDisplayBlock:true enableStringChildren:true
5+
*/
6+
/**
7+
* A grid of cards whose items are scroll containers (`overflow: hidden`),
8+
* measured under a fit-content constraint — a grid inside a flex column that
9+
* has an available height. The items' minimum contribution is 0 (css-grid-2
10+
* §6.6: a scroll container's automatic minimum size), so the auto rows start
11+
* at nothing and must grow to their items in the maximize step; a fit-content
12+
* axis with no definite size grows them as max-content does. Before the fix
13+
* every row stayed at 0 and the grid measured as a stack of gaps.
14+
*/
15+
16+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
17+
18+
import type {HostInstance} from 'react-native';
19+
20+
import ensureInstance from '../../../../src/private/__tests__/utilities/ensureInstance';
21+
import * as Fantom from '@react-native/fantom';
22+
import * as React from 'react';
23+
import {createRef} from 'react';
24+
import {Text, View} from 'react-native';
25+
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
26+
27+
function rect(ref: {current: HostInstance | null}) {
28+
return ensureInstance(
29+
ref.current,
30+
ReactNativeElement,
31+
).getBoundingClientRect();
32+
}
33+
34+
const DESC =
35+
'AppShell provides a foundational page layout with header, sidebar, and content regions. Use it to establish consistent structure across your application.';
36+
37+
describe('grid of scroll-container cards under fit-content', () => {
38+
it('sizes each row to its card', () => {
39+
const gridRef = createRef<HostInstance>();
40+
const cardRef = createRef<HostInstance>();
41+
const contentRef = createRef<HostInstance>();
42+
const root = Fantom.createRoot({viewportWidth: 402});
43+
Fantom.runTask(() => {
44+
root.render(
45+
<View style={{flexDirection: 'column', width: 402}}>
46+
<View style={{flexDirection: 'column', gap: 16}}>
47+
<Text style={{fontSize: 22}}>Core</Text>
48+
<View
49+
ref={gridRef}
50+
style={{
51+
display: 'grid',
52+
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
53+
gap: 8,
54+
margin: -8,
55+
}}>
56+
{Array.from({length: 20}, (_, i) => (
57+
<View
58+
key={i}
59+
ref={i === 0 ? cardRef : undefined}
60+
style={{
61+
display: 'block',
62+
padding: 8,
63+
overflow: 'hidden',
64+
position: 'relative',
65+
minWidth: 0,
66+
}}>
67+
<View
68+
style={{
69+
position: 'absolute',
70+
width: 1,
71+
height: 1,
72+
margin: -1,
73+
overflow: 'hidden',
74+
}}
75+
/>
76+
<View
77+
ref={i === 0 ? contentRef : undefined}
78+
style={{flexDirection: 'column', gap: 12}}>
79+
<View
80+
style={{
81+
display: 'block',
82+
minHeight: 160,
83+
minWidth: 0,
84+
overflow: 'hidden',
85+
}}
86+
/>
87+
<View style={{flexDirection: 'column', gap: 4}}>
88+
<Text style={{fontWeight: 'bold'}}>AppShell</Text>
89+
<Text numberOfLines={3}>{DESC}</Text>
90+
</View>
91+
</View>
92+
</View>
93+
))}
94+
</View>
95+
</View>
96+
</View>,
97+
);
98+
});
99+
const content = rect(contentRef).height;
100+
const card = rect(cardRef).height;
101+
const grid = rect(gridRef).height;
102+
expect(card).toBeGreaterThan(content);
103+
expect(grid).toBeGreaterThan(20 * content);
104+
});
105+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @flow strict-local
3+
* @format
4+
* @fantom_flags enableYogaDisplayBlock:true enableStringChildren:true
5+
*/
6+
/**
7+
* The accordion idiom: a grid whose one row is `0fr` while closed and `1fr`
8+
* while open, over an item with `overflow: hidden` and `min-height: 0`. The
9+
* grid's height is `auto`, which Yoga lays out in max-content mode but which
10+
* is indefinite free space rather than a max-content constraint (css-grid-2
11+
* §12.5), so the closed row takes the item's minimum contribution, 0, and
12+
* collapses. Before the fix it took the item's content height, and a drawer's
13+
* closed sections each stood as tall as their open contents.
14+
*/
15+
16+
import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
17+
18+
import type {HostInstance} from 'react-native';
19+
20+
import ensureInstance from '../../../../src/private/__tests__/utilities/ensureInstance';
21+
import * as Fantom from '@react-native/fantom';
22+
import * as React from 'react';
23+
import {createRef} from 'react';
24+
import {View} from 'react-native';
25+
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
26+
27+
function height(ref: {current: HostInstance | null}): number {
28+
return ensureInstance(ref.current, ReactNativeElement).getBoundingClientRect()
29+
.height;
30+
}
31+
32+
function section(rows: string): number {
33+
const gridRef = createRef<HostInstance>();
34+
const root = Fantom.createRoot({viewportWidth: 300});
35+
Fantom.runTask(() => {
36+
root.render(
37+
<View style={{width: 300}}>
38+
<View ref={gridRef} style={{display: 'grid', gridTemplateRows: rows}}>
39+
<View style={{overflow: 'hidden', minHeight: 0}}>
40+
<View style={{height: 100}} />
41+
</View>
42+
</View>
43+
</View>,
44+
);
45+
});
46+
return height(gridRef);
47+
}
48+
49+
describe('a single fr row over a clipping item', () => {
50+
it('collapses at 0fr', () => {
51+
expect(section('0fr')).toBe(0);
52+
});
53+
54+
it('stands as tall as its item at 1fr', () => {
55+
expect(section('1fr')).toBe(100);
56+
});
57+
});

‎packages/react-native/ReactCommon/yoga/yoga/CMakeLists.txt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ include(${YOGA_ROOT}/cmake/project-defaults.cmake)
2020

2121
file(GLOB SOURCES CONFIGURE_DEPENDS
2222
${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
23-
${CMAKE_CURRENT_SOURCE_DIR}/**/*.cpp)
23+
${CMAKE_CURRENT_SOURCE_DIR}/**/*.cpp
24+
${CMAKE_CURRENT_SOURCE_DIR}/algorithm/grid/*.cpp)
2425

2526
add_library(yogacore STATIC ${SOURCES})
2627

‎packages/react-native/ReactCommon/yoga/yoga/algorithm/CalculateLayout.cpp‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include <yoga/algorithm/PixelGrid.h>
3030
#include <yoga/algorithm/SizingMode.h>
3131
#include <yoga/algorithm/TrailingPosition.h>
32+
#include <yoga/algorithm/grid/GridLayout.h>
3233
#include <yoga/debug/AssertFatal.h>
3334
#include <yoga/debug/Log.h>
3435
#include <yoga/event/event.h>
@@ -2957,6 +2958,27 @@ static void calculateLayoutImpl(
29572958
// current node as they will not be traversed
29582959
cleanupContentsNodesRecursively(node, performLayout);
29592960

2961+
// Grid formatting context: `display: grid` uses the dedicated grid layout
2962+
// path rather than the flex algorithm below. VENDORED — see
2963+
// algorithm/grid/README-VENDORED.md.
2964+
if (node->style().display() == Display::Grid) {
2965+
calculateGridLayoutInternal(
2966+
node,
2967+
availableWidth,
2968+
availableHeight,
2969+
ownerDirection,
2970+
widthSizingMode,
2971+
heightSizingMode,
2972+
ownerWidth,
2973+
ownerHeight,
2974+
performLayout,
2975+
reason,
2976+
layoutMarkerData,
2977+
depth,
2978+
generationCount);
2979+
return;
2980+
}
2981+
29602982
// Block formatting context: `display: block` uses a dedicated block layout
29612983
// path rather than the flex algorithm below (text-children-plan.md §3.A/§4.5).
29622984
if (node->style().display() == Display::Block) {

0 commit comments

Comments
 (0)