Skip to content

feat(sandbox): Animated Layout Perf, measuring useAnimatedLayout for an item in a long list - #8629

Open
cixzhang wants to merge 5 commits into
feat/animated-layout-adoptersfrom
feat/animated-layout-perf
Open

cixzhang wants to merge 5 commits into
feat/animated-layout-adoptersfrom
feat/animated-layout-perf

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #8568, which makes useAnimatedLayout and Collapsible's animatedLayout prop public. The sandbox uses public APIs only, so this page waits for #8568 and #8482. Retarget to main once they land.

Primary intent

A fullscreen sandbox page that measures useAnimatedLayout for an item in the middle of a long list. It sits beside Markdown Perf and DiffViewer Perf and follows their layout. Sandbox only: no package change and no Changeset. It's "other" because it's a measurement tool, not a product change.

User or maintainer impact

Maintainers can see what a glide costs at scale before the module's walk changes:

  • Lists: 1k, 5k or 10k rows, with every row rendered or a virtualized window.
  • Glide: with the handle, or without it (the browser's own cost for the same list).
  • Per run (six expand/collapse toggles of the middle item), each with its change from a saved baseline:
    • commit + walk until the glide starts;
    • rectangles read;
    • elements animated;
    • time to the first frame;
    • long tasks;
    • frames and drops.

Problem and solution fit

The module walks the content after a change and reads its rectangles. Nothing so far showed what that costs in a long list, or under a slower CPU. This page does, with the controls the question needs (row count, rendering), and baselines for before/after comparisons across builds.

Authority

The request for a useAnimatedLayout perf test for items in the middle of a long list. spec:AST-105 and architecture:react-component-runtime INV12 define what the module does; this page only measures it.

Change and evidence

Real Chromium, the built sandbox page, median over 6 toggles of the middle item. "No handle" is the same list without useAnimatedLayout, so it shows the browser's own cost (each toggle's clock closes with a layout read in both modes, so the difference is the walk):

rows rendering CPU commit + walk (no handle) rects read animated long tasks (no handle) dropped frames (no handle)
1k every row 1× 21.1 ms (2.8) 1,004 9 0 (0) 0 (0)
5k every row 1× 82.7 ms (7.4) 5,004 9 6 (0) 6 (1)
10k every row 1× 160.4 ms (13.0) 10,004 9 6 (0) 10 (0)
1k / 5k / 10k virtualized 1× 7.6–7.7 ms (1.5–1.6) 40 9 0 (0) 0 (0)
1k every row 4× 92.8 ms (11.0) 1,004 9 6 (0) 6 (0)
5k every row 4× 359.1 ms (29.9) 5,004 9 12 (0) 28 (8)
10k every row 4× 705.9 ms (52.6) 10,004 9 20 (12) 55 (25)
1k / 5k / 10k virtualized 4× 30.5–34.0 ms (6.0–6.1) 40 9 1 (0) 4–7 (0)

The walk is linear in the rows after the change, and the page now shows that. #8634 bounds it and reports the after numbers from this page.

Desktop, after a run (5k rows, every row), with the middle item open:

Animated Layout Performance page at desktop with run metrics and the list

390 px. The list sits below the metrics; nothing in view moves, so 0 elements animate:

Animated Layout Performance page at 390 px

Reduce Motion, desktop. With a fine pointer the change lands at once, so 0 elements animate:

Animated Layout Performance page under Reduce Motion

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm -F @astryxdesign/sandbox build: prerender, plus export verification against the route list, which gains /pages/animated-layout-perf/.
  • Eslint and Prettier on the page.
  • The numbers above come from the built page in real Chromium, driven by Playwright, with CPU throttling through the DevTools protocol. A CI budget isn't included: the numbers need a real browser and a stable machine to mean anything.

…nimatedLayout in a long list

Cindy asked for a perf test of useAnimatedLayout for an item in the middle
of a long list. The page follows Markdown Perf's layout: heading and
description, a muted toolbar, a Run metrics card beside the output. A list
of 1k, 5k or 10k rows (every row rendered, or a virtualized window) has a
Collapsible in the middle that takes the list's handle; a run expands and
collapses it six times and reports the commit and walk until the glide
starts, rectangles read, elements animated, time to the first frame, long
tasks, frames and drops, each against a saved baseline.

Sandbox only: no package change.
@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 27, 2026 3:25am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 27, 2026, 3:50 AM UTC

@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026
… run measures its commit and the walk, not a list re-render

The open state lived on the page, so each toggle re-rendered every row
(10k rows: most of the 275 ms). The middle item now owns it, and the rows
are built once per list and window.
…, the browser's own layout cost

A Glide control (With handle / No handle) runs the same list without
useAnimatedLayout, so the walk's share of a toggle is the difference.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the page runs as described in Chromium, and its numbers show the walk growing with the rows after the change, but its comparison flow can mislead whoever reads the numbers. Please fix these before it lands with its base:

  • Saved baselines live only in component state, so the reload a build change needs clears them, although the help text and description promise cross-build comparisons (page.tsx:190, :475-477).
  • Stop publishes a partial run under the fixed "6 toggles" badge and lets it be saved as the baseline (page.tsx:429, :444).
  • With six toggles, median() returns the upper middle value of three opens and three closes, so it reports only one kind of toggle (page.tsx:120-125).
  • The middle item's copy says every row after it glides and that the walk visits the rows around it; only the rows in view move, and the walk reads the elements after the item (page.tsx:168-172).

[Automated review]

Comment on lines +475 to +477
frame is a measured animation frame gap over 20 ms. Save a
baseline, change the build or the CPU throttle in DevTools, and
run again to compare.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Baselines live only in useState (line 190), so the reload a build change needs clears them, and every metric then reads "no baseline". Persist them in sessionStorage or localStorage keyed by rows:rendering:glide, or limit this sentence to in-session changes such as the CPU throttle.

label="Save as baseline"
variant="secondary"
onClick={saveBaseline}
isDisabled={isRunning || metrics.walkMs == null}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After Stop, the partial run's metrics are published under the fixed "6 toggles" badge (line 444), and this button stays enabled because walkMs is set. A run stopped after three toggles showed Frames 168 and Long tasks 3, against 335 and 6 for a full run. Leave the metrics empty when a run is stopped, or record the completed toggle count, show it in the badge, and enable Save only for a complete run.

return null;
}
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)] ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With six toggles this returns sorted[3], the upper of the two middle values. A run alternates three opens and three closes, so this reports the cheapest toggle of whichever kind costs more (Rectangles read: 5,007 for a full 5,000-row run, 5,001 for a three-toggle run). Average the two middle values, or report opens and closes separately.

Comment on lines +168 to +172
Expanding this item glides every row after it in the list.
</Text>
<Text type="supporting">
The walk visits the rows around it, at each level up to the list,
and moves only those whose rectangle changed and that are in view.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the rows in view glide (9 of the roughly 2,499 after this item), and the walk reads the elements after the item, not around it. Suggested copy: "Expanding this item moves the rows after it that are in view. The walk reads every element after it, at each level up to the list, before and after the change."

…ports nothing for a stopped run, and takes a true median

- Saved baselines go to localStorage per rows, rendering and glide, so the
  reload a build change needs keeps them (read after hydration).
- A stopped run publishes no metrics, so a partial run can neither read as
  a full one nor be saved as a baseline.
- The median of an even count is the mean of its two middle values: a
  run's three opens and three closes both count.
- The middle item's copy says what moves (the rows after it that are in
  view) and what the page measures.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: the page builds, its route exports, and the earlier findings are fixed, but two states still mislead whoever reads its numbers. Please fix these before it lands with its base:

  • No handle runs stop the clock before the browser's style and layout, so the help text's "with handle minus no handle" charges the walk about 5 ms at 5,000 rows and 9 ms at 10,000 that the browser spends anyway; add a layout read such as void viewport.offsetHeight; before walks.push (page.tsx:327), or say that No handle times React's commit only.
  • After a reload every metric reads "no baseline" although the baseline is saved; show "no baseline" only when none is saved for the configuration (page.tsx:109-112).

[Automated review]

// The layout walk runs in a microtask after the commit.
await Promise.resolve();
await Promise.resolve();
walks.push(performance.now() - start);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With No handle nothing between start and this line reads layout, so the browser's style and layout for the change run after the clock stops (4.3–5.5 ms at 5,000 rows and 8.3–10.6 ms at 10,000 in Chromium), while with the handle the walk forces that same layout inside the window. A layout read the counter doesn't see, such as void viewport.offsetHeight; just before this line, puts it in both modes, so with-minus-without is the walk alone; otherwise the help text at line 514 should say No handle times React's commit only.


/** A value's change from its baseline, as the other perf pages show it. */
function formatChange(value: number | null, base: number | null): string {
if (value == null || base == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With no finished run value is null, so every metric reads "no baseline" even when a baseline for this configuration is saved, which is what a maintainer sees right after the reload the help text says baselines survive. Return "no baseline" only when base == null, and show the saved value (or "baseline saved") while value is null.

…th modes, and a saved baseline reads as saved after a reload

- An uncounted layout read closes each toggle's clock, so No handle times
  the browser's own style and layout for the change too, and with the
  handle minus without is the walk alone.
- A metric reads no baseline only when none is saved for the
  configuration; before a run it reads baseline saved.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval recommended: the page builds, its route exports, and the two issues from the last review are fixed, so No handle now includes the browser's layout and a saved baseline reads as saved after a reload. Before #8634 compares against it, consider refreshing the description's table, which predates the last two commits (it shows 5,007 and 10,007 rectangles, and No handle times taken without the browser's layout). The page can land with or after its base, since useAnimatedLayout and Collapsible's animatedLayout prop are not on main yet.

[Automated review]

Comment on lines +236 to +237
// Virtualized: only the rows in (and just around) the viewport render; the
// middle item always renders, so the change always happens in the DOM.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this says the middle item always renders, but the loop at lines 392-407 renders it only inside the window, so in Virtualized mode it is absent from the DOM until a run scrolls to it. Every run scrolls there before its first toggle, so the numbers are unaffected; rewording the comment would match the code.

@github-actions
github-actions Bot deleted the branch feat/animated-layout-adopters October 28, 2026 06:17

This branch was successfully deployed

1 active deployment
Preview — e13e8b4d Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant