Grid: Mount tiles after measuring the container by louwie17 · Pull Request #86508 · WordPress/gutenberg · GitHub
Skip to content

Grid: Mount tiles after measuring the container - #86508

Draft
louwie17 wants to merge 3 commits into
trunkfrom
fix/grid-mount-after-measure
Draft

louwie17 wants to merge 3 commits into
trunkfrom
fix/grid-mount-after-measure

Conversation

@louwie17

@louwie17 louwie17 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What?

DashboardGrid and DashboardLanes now mount their tiles after they first measure their container. A tile's first commit already has the measured column count.

Why?

Both surfaces start with a container width of 0 and measure it in a layout effect. A responsive policy (columns as steps, or minColumnWidth) resolves to one column at width 0, so the first commit gives every tile grid-column-end: span 1 at full width. The measurement then sets the real count, before paint.

React runs a component's layout effects after those of its children. A tile that reads its span or size when it mounts, in a layout effect or a ResizeObserver, sees the one-column layout. Measuring sooner in the surface does not help, because the children have already read it. If the tile's width does not change, for example a three-column tile that goes from span 1 to span 3 at the same width, its ResizeObserver never fires again and the tile keeps the layout it picked for one column.

This shows up when a host remounts widgets whose modules are already loaded, such as when it switches back to a dashboard tab: the widget body renders in the same commit as its tile. Jetpack's Stats dashboard hit this and works around it by watching the tile's style with a MutationObserver (Automattic/jetpack#54676). Any other consumer that reads span or size at mount has the same problem.

How can a tile see a known width but the wrong columns?

The width and the column count come from two different places. The width is the browser's layout: once React inserts the grid into the DOM, the browser lays it out at its real width, and a child that calls getBoundingClientRect() gets that width. The column count is React state: the surface computes repeat(N, …) and each tile's grid-column-end: span … from containerWidth, which starts at useState( 0 ) and only becomes the real width after the surface's own layout effect measures the DOM. Until then the DOM has the real width but a one-column track, and in one column a tile is full width.

On trunk the measurement also ran one commit late. The effect depended on gridRoot, which is state set by a ref callback, so in the first commit it saw null and returned. The order was: commit 1 (width 0, one column) → tiles' layout effects read span 1 → the surface's effect skips → commit 2 (gridRoot set, width still 0) → tiles that re-render still see span 1 → the surface measures → commit 3 with the real count. All of this is before the first paint, so no one sees a one-column grid, but a tile that chose its layout in a layout effect keeps the one-column choice.

How?

Each surface measures through a ref in a layout effect that runs in its first commit, instead of waiting for the gridRoot (or container) state, and keeps a hasMeasured flag that the effect sets. Until then the surface renders its root, the track template and the grid overlay, but not the tiles or the unplaced children. Measuring earlier alone is not enough, because tiles' layout effects run before the surface's; holding the tiles back is what guarantees none of them reads the unmeasured count. Setting the flag in a layout effect makes React render again synchronously before paint, in the same pass that applies the measured width, so the tiles mount once with the measured column count and no empty frame is painted.

The surface itself now commits twice before paint: once empty to measure, once with the tiles. A React Profiler count of DashboardGrid at 1000px went from four commits on trunk to four with this change, but with the tiles mounted only once and with the measured columns. Two of the four commits are not about columns: dnd-kit registers each tile's droppable from an effect on mount, and the ResizeObserver reports the grid's height after paint for the overlay's rows. Both happen on trunk too and are left for follow-ups.

useItemExitAnimation also called setExitingItems( [] ) with a new array whenever the container changed, which cost the surface an extra commit on mount. It now keeps the current array when nothing is exiting.

The flag does not depend on the measured width. When the container measures 0 (it is hidden, or the test runs in jsdom), tiles mount as they did before and the ResizeObserver updates them later. Column-step resolution is unchanged.

Options considered:

  • Expose the resolved span through context or a hook, such as useGridItemSpan(). Consumers would no longer have to parse computed CSS, but it adds public API and does not fix a tile that measures its own size at mount. It could still come later.
  • Defer widgets in @wordpress/widget-dashboard. That fixes only that consumer, and the surface owns the measurement, so the fix belongs in @wordpress/grid.

On the server, layout effects do not run, so server-rendered markup contains the empty surface without tiles. Neither surface was producing a correct layout on the server before this change, since the width is unknown there, and @wordpress/widget-dashboard renders on the client.

Testing Instructions

  1. Run npm run test:unit -- packages/widget-dashboard/src/test/column-count.browser.test.tsx.
  2. The new "first render" tests mount a dashboard at 1000px with columns: { 0: 1, 960: 3 }, unmount it, and mount it again so the widget body renders from the module cache in the same commit as its tile. A probe widget records its tile's grid-column-end and width in a layout effect when it mounts.
  3. Before this change both fail:
    • Grid: expected { span: 'span 1', width: 1000 } to deeply equal { span: 'span 3', width: 1000 }
    • Masonry: expected 1000 to be 333.328125
  4. After this change both pass, together with the rest of the @wordpress/grid and @wordpress/widget-dashboard tests.

The masonry test compares width only, because the lane polyfill places tiles in rows after it measures their heights.

Testing Instructions for Keyboard

No UI change. Keyboard reordering and resizing are covered by the existing keyboard-activation tests, which pass.

Use of AI Tools

Claude Code (Claude Opus 5.5) investigated the cause, wrote the fix and the tests, and drafted this description. I reviewed the change and the results.

🤖 Generated with Claude Code

https://claude.ai/code/session_015zLZuzKAxkbg7pY4W6xJB2

@louwie17 louwie17 added [Type] Bug An existing feature does not function as intended [Package] Grid /packages/grid labels Nov 9, 2026
@github-actions github-actions Bot added the [Package] Widget Dashboard /packages/widget-dashboard label Nov 9, 2026
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground Preview in Storybook

📦 Bundle size

Size Change: 0 B

Total Size: 8.63 MB

da3eaa2 Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 3c044b7 trunk % Change
timeToFirstByte 46.4 ms +5.17% -3.45% 46.05 ms +4.45% -3.04% 0.76%
largestContentfulPaint 80 ms +10% -0% 78 ms +5.13% -2.56% 2.56%
lcpMinusTtfb 34.7 ms +17.29% -5.91% 32.1 ms +9.81% -3.43% 8.1%
wpBeforeTemplate 23.26 ms +4.86% -1.93% 23.26 ms +5.16% -1.93% 0%
wpTemplate 19.14 ms +4.34% -6.06% 18.93 ms +3.75% -3.75% 1.11%
wpTotal 42.58 ms +5.68% -3.59% 42.34 ms +4.98% -2.88% 0.57%
wpMemoryUsage 7.72 MB +0% -0% 7.69 MB +0% -0% 0.43%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric 3c044b7 trunk % Change
timeToFirstByte 38.85 ms +1.42% -1.42% 38.8 ms +3.87% -1.03% 0.13%
largestContentfulPaint 88 ms +4.55% -0% 88 ms +4.55% -0% 0%
lcpMinusTtfb 50.5 ms +4.55% -2.38% 50 ms +3.3% -1.4% 1%
wpBeforeTemplate 21.04 ms +3.52% -1.62% 20.84 ms +5.57% -0.77% 0.96%
wpTemplate 14.69 ms +0.68% -2.79% 14.85 ms +3.03% -3.7% -1.08%
wpTotal 35.81 ms +1.37% -1.06% 35.88 ms +4.54% -1.06% -0.2%
wpMemoryUsage 6.41 MB +0% -0% 6.38 MB +0% -0% 0.53%
wpDbQueries 13 +0% -0% 13 +0% -0% 0%

media-processing

Metric 3c044b7 trunk % Change
mediaProcessingJpeg 407.49 ms +3.66% -1.67% 408.61 ms +0.76% -0.52% -0.27%
mediaProcessingAvif 6335.92 ms +0.18% -0.33% 6344.35 ms +0.33% -0.51% -0.13%
mediaProcessingJpegToAvif 4343.32 ms +0.19% -0.19% 4344.88 ms +0.5% -0.2% -0.04%

media-upload

Metric 3c044b7 trunk % Change
jpegUploadProcessing 1477.21 ms +34.23% -1.79% 1457.08 ms +1.1% -0.57% 1.38%
pngUploadProcessing 229.47 ms +5.24% -1.62% 238 ms +0.41% -1.35% -3.58%
largeJpegUploadProcessing 1450.89 ms +0.2% -0.31% 1443.43 ms +0.8% -0.09% 0.52%
multipleImageUploadProcessing 1670.78 ms +24.98% -4.56% 1586.93 ms +9.96% -0.78% 5.28%

post-editor

Metric 3c044b7 trunk % Change
serverResponse 274.32 ms +2.71% -9.1% 253.26 ms +10.06% -1.97% 8.32%
firstPaint 335.09 ms +1.85% -19.49% 255.22 ms +27.64% -21.55% 31.29%
domContentLoaded 581.05 ms +0.94% -2.51% 599.01 ms +4.02% -1.09% -3%
loaded 581.96 ms +0.93% -2.51% 599.86 ms +4.03% -1.08% -2.98%
firstContentfulPaint 344.55 ms +2.33% -0.97% 354.27 ms +5.42% -3.61% -2.74%
firstBlock 1493.48 ms +1.19% -0.66% 1528.58 ms +1.35% -2.08% -2.3%
type 14.17 ms +5.08% -6.35% 12.61 ms +5.31% -11.82% 12.37%
typeWithoutInspector 13.59 ms +2.8% -8.24% 13.03 ms +3.3% -9.13% 4.3%
typeWithTopToolbar 15.86 ms +6.05% -8.45% 17.02 ms +18.33% -10.58% -6.82%
typeContainer 6.28 ms +3.98% -7.96% 6.82 ms +4.25% -7.92% -7.92%
focus 75.74 ms +8.38% -12.65% 80.69 ms +3.22% -2.78% -6.13%
firstFocus 175.59 ms +0% -0% 170.22 ms +0% -0% 3.15%
selectAll 245.59 ms +1.88% -4.05% 260.53 ms +1.06% -0.51% -5.73%
undo 19.44 ms +10.55% -8.18% 23.4 ms +6.54% -2.74% -16.92%
listViewOpen 48.5 ms +19.03% -7.63% 50.68 ms +8.03% -7.32% -4.3%
inserterOpen 27.77 ms +11.34% -4.25% 29.87 ms +6.13% -12.32% -7.03%
inserterHover 8.09 ms +4.33% -6.92% 8.91 ms +6.17% -5.95% -9.2%
inserterSearch 7.11 ms +2.67% -4.22% 7.05 ms +13.9% -4.26% 0.85%
loadPatterns 438.05 ms +4.49% -2.94% 462.76 ms +3.45% -2.52% -5.34%
wpTotal 267.41 ms +2.71% -9.38% 246.25 ms +10.32% -2% 8.59%
wpMemoryUsage 14.77 MB +0% -0% 14.74 MB +0% -0% 0.24%
wpDbQueries 55 +0% -0% 55 +0% -0% 0%

site-editor

Metric 3c044b7 trunk % Change
serverResponse 452.99 ms +5.91% -5.12% 451.31 ms +4.91% -4.23% 0.37%
firstPaint 370.04 ms +6.17% -6.58% 371.49 ms +6.48% -9.24% -0.39%
domContentLoaded 898.45 ms +0.99% -2.62% 881.24 ms +1.71% -0.79% 1.95%
loaded 899.64 ms +0.99% -2.6% 882.27 ms +1.71% -0.75% 1.97%
firstContentfulPaint 521.32 ms +0.88% -2.27% 513.59 ms +1.75% -2.01% 1.51%
firstBlock 2696.02 ms +0.93% -0.33% 2669.43 ms +0.69% -0.31% 1%
type 19.76 ms +5.06% -5.67% 19.96 ms +4.46% -8.17% -1%
navigate 118.59 ms +13.81% -6.99% 109.2 ms +12.52% -0.63% 8.6%
loadPatterns 1333.92 ms +13.26% -12.01% 1277.39 ms +14.16% -13.09% 4.43%
loadPages 987.15 ms +2.76% -2.2% 1017.13 ms +2.63% -5.38% -2.95%
wpTotal 443.16 ms +6.04% -5.18% 441.6 ms +5.01% -4.3% 0.35%
wpMemoryUsage 12.79 MB +0% -0% 12.75 MB +0% -0% 0.27%
wpDbQueries 45 +0% -0% 45 +0% -0% 0%

da3eaa2 Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

Page List converts to Navigation Links with entity bindings in /test/e2e/specs/editor/blocks/navigation.spec.js, passed after 1 failed attempt.
Error: expect(locator).toContainText(expected) failed

Locator: getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ })
Expected substring: "test-page-1"
Received string:    "Link to:Cat/catPagePublished"
Timeout: 5000ms

Call log:
  - Expect "toContainText" getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ }) with timeout 5000ms
  - waiting for getByRole('region', { name: 'Editor settings' }).getByRole('tabpanel', { name: 'Content' }).getByRole('button', { name: /Link to:/ })
    14 × locator resolved to <button type="button" aria-expanded="false" aria-haspopup="dialog" class="components-button link-preview-button is-next-40px-default-size is-secondary">…</button>
       - unexpected value "Link to:Cat/catPagePublished"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/navigation.spec.js:1949:31

Should display legacy widgets in /test/e2e/specs/widgets/editing-widgets.spec.js, passed after 1 failed attempt.

does not select blocks in the canvas preview when editing from the sidebar (Site Editor v2) in /test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js, passed after 1 failed attempt.
Error: expect(locator).toBeFocused() failed

Locator: getByRole('combobox', { name: 'Search or type URL' })
Expected: focused
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeFocused" getByRole('combobox', { name: 'Search or type URL' }) with timeout 5000ms
  - waiting for getByRole('combobox', { name: 'Search or type URL' })

    at LinkControl.useLinkControlSearch (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js:416:47)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js:179:21
should only mutate text data on input in /test/e2e/specs/editor/various/rich-text.spec.js, passed after 1 failed attempt.
Error: expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 4

- Array []
+ Array [
+   "Typing should only mutate once.",
+   "Typing mutated more than character data: attributes",
+ ]
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/rich-text.spec.js:460:20
should select with shift + click in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 2 failed attempts.
Error: expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 0

  Array [
-   1,
    2,
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:380:5
Error: expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 0

  Array [
-   1,
    2,
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:380:5

da3eaa2 Run

louwie17 and others added 3 commits November 9, 2026 15:18
DashboardGrid and DashboardLanes start with a container width of 0 and
measure it in a layout effect. Children run their layout effects first,
so a tile that read its span or size at mount saw the one-column layout.
Mount tiles only after the first measurement; the update renders
synchronously before paint, so no empty frame shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zLZuzKAxkbg7pY4W6xJB2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zLZuzKAxkbg7pY4W6xJB2
Measure through a ref in a layout effect that runs in the surface's
first commit, instead of waiting for the root element state set by the
ref callback. Tiles now mount in the second commit, with the measured
column count.

Keep the exit animation's empty array when nothing is exiting, so a
container change does not cost the surface an extra commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zLZuzKAxkbg7pY4W6xJB2

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Grid /packages/grid [Package] Widget Dashboard /packages/widget-dashboard [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant