From 1d856c142233e9500d9faf5b95e4541d28731052 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 17:31:26 -0700 Subject: [PATCH 1/5] feat(sandbox): Animated Layout Perf, a fullscreen page measuring useAnimatedLayout 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. --- apps/sandbox/scripts/next-export-routes.json | 1 + .../pages/animated-layout-perf/page.tsx | 451 ++++++++++++++++++ apps/sandbox/src/app/sandboxPages.ts | 6 + 3 files changed, 458 insertions(+) create mode 100644 apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json index 09bb9b00dcc35..91f1ff7d2c5c1 100644 --- a/apps/sandbox/scripts/next-export-routes.json +++ b/apps/sandbox/scripts/next-export-routes.json @@ -5,6 +5,7 @@ "/", "/404/", "/components-patterns/", + "/pages/animated-layout-perf/", "/pages/butter-palette/", "/pages/codeblock-perf/", "/pages/color-studio/", diff --git a/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx new file mode 100644 index 0000000000000..b759d78a64473 --- /dev/null +++ b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx @@ -0,0 +1,451 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file page.tsx + * @input A long list of rows (1k, 5k or 10k; every row rendered, or only + * the visible window) with a Collapsible in the middle that takes the + * list's `useAnimatedLayout` handle + * @output Run metrics for expanding and collapsing that middle item: the + * commit and layout walk until the glide starts, rectangles read, elements + * animated, long tasks, frames and drops, and time to the first frame, + * each with its change from a saved baseline + * @position Fullscreen sandbox tool, beside Markdown Perf and DiffViewer + * Perf, for measuring `useAnimatedLayout` at scale + */ + +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {flushSync} from 'react-dom'; +import * as stylex from '@stylexjs/stylex'; +import {AppShell} from '@astryxdesign/core/AppShell'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Button} from '@astryxdesign/core/Button'; +import {Card} from '@astryxdesign/core/Card'; +import {Collapsible} from '@astryxdesign/core/Collapsible'; +import {Grid} from '@astryxdesign/core/Grid'; +import {useAnimatedLayout} from '@astryxdesign/core/hooks'; +import {ScrollableArea} from '@astryxdesign/core/ScrollableArea'; +import {Section} from '@astryxdesign/core/Section'; +import { + SegmentedControl, + SegmentedControlItem, +} from '@astryxdesign/core/SegmentedControl'; +import {HStack, VStack} from '@astryxdesign/core/Stack'; +import {Heading, Text} from '@astryxdesign/core/Text'; + +type Rendering = 'full' | 'virtualized'; + +interface RunMetrics { + /** From the change to the glide starting: React's commit and the walk. */ + walkMs: number | null; + rectsRead: number | null; + animated: number | null; + longTasks: number | null; + longTaskMs: number | null; + frames: number | null; + droppedFrames: number | null; + firstFrameMs: number | null; +} + +const EMPTY_METRICS: RunMetrics = { + walkMs: null, + rectsRead: null, + animated: null, + longTasks: null, + longTaskMs: null, + frames: null, + droppedFrames: null, + firstFrameMs: null, +}; + +const ROW_OPTIONS = ['1000', '5000', '10000']; +const ROW_HEIGHT = 36; +const VIEWPORT_HEIGHT = 560; +/** Rows rendered past each edge of the visible window when virtualized. */ +const OVERSCAN = 8; +/** Expand and collapse, three times each, for one run. */ +const TOGGLES = 6; +/** Long enough for the glide (springs.settle) to finish between toggles. */ +const SETTLE_MS = 900; + +const styles = stylex.create({ + row: { + height: ROW_HEIGHT, + paddingInline: 12, + borderBottomWidth: 1, + borderBottomStyle: 'solid', + borderBottomColor: 'var(--color-border)', + boxSizing: 'border-box', + }, + middle: { + paddingInline: 12, + paddingBlock: 4, + borderBottomWidth: 1, + borderBottomStyle: 'solid', + borderBottomColor: 'var(--color-border)', + }, + spacer: (height: number) => ({height}), +}); + +function formatMs(value: number | null): string { + return value == null ? '—' : `${value.toFixed(1)} ms`; +} + +function formatCount(value: number | null): string { + return value == null ? '—' : value.toLocaleString(); +} + +/** 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) { + return 'no baseline'; + } + if (base === 0) { + return value === 0 ? '±0%' : 'new'; + } + const change = ((value - base) / base) * 100; + return `${change >= 0 ? '+' : ''}${change.toFixed(0)}%`; +} + +function median(values: number[]): number | null { + if (values.length === 0) { + return null; + } + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] ?? null; +} + +const nextFrame = async (): Promise => + new Promise(resolve => { + requestAnimationFrame(resolve); + }); + +const wait = async (ms: number): Promise => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +export default function AnimatedLayoutPerfPage() { + const [rowCount, setRowCount] = useState('5000'); + const [rendering, setRendering] = useState('full'); + const [isOpen, setIsOpen] = useState(false); + const [isRunning, setIsRunning] = useState(false); + const [metrics, setMetrics] = useState(EMPTY_METRICS); + const [baselines, setBaselines] = useState>({}); + const [scrollTop, setScrollTop] = useState(0); + + const viewportRef = useRef(null); + const animatedLayout = useAnimatedLayout({containerRef: viewportRef}); + const runningRef = useRef(false); + + const count = Number(rowCount); + const middle = Math.floor(count / 2); + const key = `${rowCount}:${rendering}`; + const baseline = baselines[key]; + + // Virtualized: only the rows in (and just around) the viewport render; the + // middle item always renders, so the change always happens in the DOM. + const windowRange = useMemo(() => { + if (rendering === 'full') { + return {start: 0, end: count}; + } + const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visible = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2; + return {start: first, end: Math.min(count, first + visible)}; + }, [count, rendering, scrollTop]); + + useEffect(() => { + const viewport = viewportRef.current; + if (viewport == null || rendering === 'full') { + return undefined; + } + const onScroll = () => setScrollTop(viewport.scrollTop); + viewport.addEventListener('scroll', onScroll, {passive: true}); + return () => viewport.removeEventListener('scroll', onScroll); + }, [rendering]); + + useEffect(() => { + if (!runningRef.current) { + setMetrics(EMPTY_METRICS); + } + }, [key]); + + const run = useCallback(async () => { + const viewport = viewportRef.current; + if (viewport == null || runningRef.current) { + return; + } + runningRef.current = true; + setIsRunning(true); + setMetrics(EMPTY_METRICS); + flushSync(() => setIsOpen(false)); + // The middle item, centered in the viewport. + viewport.scrollTop = Math.max( + 0, + middle * ROW_HEIGHT - VIEWPORT_HEIGHT / 2 + ROW_HEIGHT, + ); + setScrollTop(viewport.scrollTop); + await wait(SETTLE_MS); + + // Instrumentation, for this run only: every rectangle read on the page, + // long tasks, and animation frames. + const prototype = Element.prototype; + const original = prototype.getBoundingClientRect; + let rects = 0; + prototype.getBoundingClientRect = function getBoundingClientRect( + this: Element, + ) { + rects += 1; + return original.call(this); + }; + let longTasks = 0; + let longTaskMs = 0; + const observer = + typeof PerformanceObserver !== 'undefined' && + PerformanceObserver.supportedEntryTypes?.includes('longtask') + ? new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + longTasks += 1; + longTaskMs += entry.duration; + } + }) + : null; + observer?.observe({type: 'longtask', buffered: false}); + let frames = 0; + let dropped = 0; + let previous = performance.now(); + let frameId: number; + const onFrame = (now: number) => { + frames += 1; + if (now - previous > 20) { + dropped += 1; + } + previous = now; + frameId = requestAnimationFrame(onFrame); + }; + frameId = requestAnimationFrame(onFrame); + + const walks: number[] = []; + const reads: number[] = []; + const animations: number[] = []; + const firstFrames: number[] = []; + try { + for (let i = 0; i < TOGGLES && runningRef.current; i++) { + rects = 0; + const start = performance.now(); + flushSync(() => setIsOpen(open => !open)); + // The layout walk runs in a microtask after the commit. + await Promise.resolve(); + await Promise.resolve(); + walks.push(performance.now() - start); + reads.push(rects); + animations.push( + viewport + .getAnimations({subtree: true}) + .filter(animation => + (animation.effect as KeyframeEffect | null) + ?.getKeyframes() + .some(frame => 'translate' in frame), + ).length, + ); + await nextFrame(); + firstFrames.push(performance.now() - start); + await wait(SETTLE_MS); + } + } finally { + prototype.getBoundingClientRect = original; + observer?.disconnect(); + cancelAnimationFrame(frameId); + } + const next: RunMetrics = { + walkMs: median(walks), + rectsRead: median(reads), + animated: median(animations), + longTasks, + longTaskMs, + frames, + droppedFrames: dropped, + firstFrameMs: median(firstFrames), + }; + setMetrics(next); + runningRef.current = false; + setIsRunning(false); + }, [middle]); + + const stop = useCallback(() => { + runningRef.current = false; + }, []); + + const saveBaseline = useCallback(() => { + setBaselines(current => ({...current, [key]: metrics})); + }, [key, metrics]); + + const rows = []; + for (let i = windowRange.start; i < windowRange.end; i++) { + if (i === middle) { + rows.push( + + + + + Expanding this item glides every row after it in the list. + + + The walk visits the rows after it, at each level up to the list, + and moves only those whose rectangle changed and that are in + view. + + + Closing it plays its exit while the rows below glide up. + + + + , + ); + continue; + } + rows.push( + + Row {i + 1} + , + ); + } + + return ( + + + + Animated Layout Performance + + Expand and collapse an item in the middle of a long list through + useAnimatedLayout, and measure the layout walk, the rectangles it + reads, the elements it animates, long tasks and dropped frames. + + + +
+ + + {ROW_OPTIONS.map(value => ( + + ))} + + setRendering(value as Rendering)} + isDisabled={isRunning} + disabledMessage="Stop the active run before changing rendering." + size="sm"> + + + +
+ + + + + Run metrics + + + + + + + {( + [ + ['Commit + walk', formatMs, 'walkMs'], + ['Rectangles read', formatCount, 'rectsRead'], + ['Elements animated', formatCount, 'animated'], + ['Time to first frame', formatMs, 'firstFrameMs'], + ['Long tasks', formatCount, 'longTasks'], + ['Long task time', formatMs, 'longTaskMs'], + ['Frames', formatCount, 'frames'], + ['Dropped frames', formatCount, 'droppedFrames'], + ] as const + ).map(([label, format, field]) => ( + + {label} + + {format(metrics[field])} ·{' '} + {formatChange(metrics[field], baseline?.[field] ?? null)} + + + ))} + + + Commit + walk, rectangles read, elements animated and time to + first frame are medians over the run's toggles; the rest + are totals for the run. Commit + walk runs from the change to + the glide starting: React's commit of the item, then the + walk and its reads. A dropped 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. + + + + + + + + {rendering === 'virtualized' && ( + + )} + {rows} + {rendering === 'virtualized' && ( + + )} + + + + +
+
+ ); +} diff --git a/apps/sandbox/src/app/sandboxPages.ts b/apps/sandbox/src/app/sandboxPages.ts index 4c878d8c463a6..c1c7974cc8c51 100644 --- a/apps/sandbox/src/app/sandboxPages.ts +++ b/apps/sandbox/src/app/sandboxPages.ts @@ -251,6 +251,12 @@ export const categories: SandboxCategory[] = [ description: 'Compare complete Markdown rendering with bursty streaming performance and animation', }, + { + name: 'Animated Layout Perf', + href: '/pages/animated-layout-perf/', + description: + 'Expand and collapse an item in the middle of a long list through useAnimatedLayout and measure the walk', + }, { name: 'DiffViewer Perf', href: '/pages/diffviewer-perf/', From e8cb763b95cc51518affd3149563f186bdbdce20 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 17:54:01 -0700 Subject: [PATCH 2/5] fix(sandbox): Animated Layout Perf toggles only the middle item, so a 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. --- .../pages/animated-layout-perf/page.tsx | 114 ++++++++++++------ 1 file changed, 78 insertions(+), 36 deletions(-) diff --git a/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx index b759d78a64473..856de24781a66 100644 --- a/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx +++ b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx @@ -15,7 +15,15 @@ * Perf, for measuring `useAnimatedLayout` at scale */ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type RefObject, +} from 'react'; import {flushSync} from 'react-dom'; import * as stylex from '@stylexjs/stylex'; import {AppShell} from '@astryxdesign/core/AppShell'; @@ -33,6 +41,7 @@ import { } from '@astryxdesign/core/SegmentedControl'; import {HStack, VStack} from '@astryxdesign/core/Stack'; import {Heading, Text} from '@astryxdesign/core/Text'; +import type {AnimatedLayout} from '@astryxdesign/core/hooks'; type Rendering = 'full' | 'virtualized'; @@ -126,10 +135,55 @@ const wait = async (ms: number): Promise => setTimeout(resolve, ms); }); +/** + * The item that changes. Its open state is its own, so a toggle re-renders + * it alone: the run measures the item's commit and the walk, not a list + * re-render. + */ +const MiddleItem = memo(function MiddleItem({ + index, + animatedLayout, + toggleRef, +}: { + index: number; + animatedLayout: AnimatedLayout; + toggleRef: RefObject<((open?: boolean) => void) | null>; +}) { + const [isOpen, setIsOpen] = useState(false); + useEffect(() => { + toggleRef.current = open => setIsOpen(current => open ?? !current); + return () => { + toggleRef.current = null; + }; + }, [toggleRef]); + return ( + + + + + Expanding this item glides every row after it in the list. + + + 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. + + + Closing it plays its exit while the rows below glide up. + + + + + ); +}); + export default function AnimatedLayoutPerfPage() { const [rowCount, setRowCount] = useState('5000'); const [rendering, setRendering] = useState('full'); - const [isOpen, setIsOpen] = useState(false); + const toggleRef = useRef<((open?: boolean) => void) | null>(null); const [isRunning, setIsRunning] = useState(false); const [metrics, setMetrics] = useState(EMPTY_METRICS); const [baselines, setBaselines] = useState>({}); @@ -179,7 +233,7 @@ export default function AnimatedLayoutPerfPage() { runningRef.current = true; setIsRunning(true); setMetrics(EMPTY_METRICS); - flushSync(() => setIsOpen(false)); + flushSync(() => toggleRef.current?.(false)); // The middle item, centered in the viewport. viewport.scrollTop = Math.max( 0, @@ -234,7 +288,7 @@ export default function AnimatedLayoutPerfPage() { for (let i = 0; i < TOGGLES && runningRef.current; i++) { rects = 0; const start = performance.now(); - flushSync(() => setIsOpen(open => !open)); + flushSync(() => toggleRef.current?.()); // The layout walk runs in a microtask after the commit. await Promise.resolve(); await Promise.resolve(); @@ -281,40 +335,28 @@ export default function AnimatedLayoutPerfPage() { setBaselines(current => ({...current, [key]: metrics})); }, [key, metrics]); - const rows = []; - for (let i = windowRange.start; i < windowRange.end; i++) { - if (i === middle) { - rows.push( - - - - - Expanding this item glides every row after it in the list. - - - The walk visits the rows after it, at each level up to the list, - and moves only those whose rectangle changed and that are in - view. - - - Closing it plays its exit while the rows below glide up. - - - - , + // The rows, built once per list and window: a toggle re-renders only the + // middle item. + const rows = useMemo(() => { + const out = []; + for (let i = windowRange.start; i < windowRange.end; i++) { + out.push( + i === middle ? ( + + ) : ( + + Row {i + 1} + + ), ); - continue; } - rows.push( - - Row {i + 1} - , - ); - } + return out; + }, [animatedLayout, middle, windowRange]); return ( From 87f470a99902bc5a17cdbd19d8ba096908921781 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 26 Oct 2026 18:16:00 -0700 Subject: [PATCH 3/5] feat(sandbox): Animated Layout Perf compares a run without the handle, 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. --- .../pages/animated-layout-perf/page.tsx | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx index 856de24781a66..228034219339c 100644 --- a/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx +++ b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx @@ -146,7 +146,7 @@ const MiddleItem = memo(function MiddleItem({ toggleRef, }: { index: number; - animatedLayout: AnimatedLayout; + animatedLayout: AnimatedLayout | undefined; toggleRef: RefObject<((open?: boolean) => void) | null>; }) { const [isOpen, setIsOpen] = useState(false); @@ -183,6 +183,7 @@ const MiddleItem = memo(function MiddleItem({ export default function AnimatedLayoutPerfPage() { const [rowCount, setRowCount] = useState('5000'); const [rendering, setRendering] = useState('full'); + const [glide, setGlide] = useState<'on' | 'off'>('on'); const toggleRef = useRef<((open?: boolean) => void) | null>(null); const [isRunning, setIsRunning] = useState(false); const [metrics, setMetrics] = useState(EMPTY_METRICS); @@ -195,7 +196,8 @@ export default function AnimatedLayoutPerfPage() { const count = Number(rowCount); const middle = Math.floor(count / 2); - const key = `${rowCount}:${rendering}`; + const key = `${rowCount}:${rendering}:${glide}`; + const handle = glide === 'on' ? animatedLayout : undefined; const baseline = baselines[key]; // Virtualized: only the rows in (and just around) the viewport render; the @@ -345,7 +347,7 @@ export default function AnimatedLayoutPerfPage() { ) : ( @@ -356,7 +358,7 @@ export default function AnimatedLayoutPerfPage() { ); } return out; - }, [animatedLayout, middle, windowRange]); + }, [handle, middle, windowRange]); return ( @@ -397,6 +399,16 @@ export default function AnimatedLayoutPerfPage() { + setGlide(value as 'on' | 'off')} + isDisabled={isRunning} + disabledMessage="Stop the active run before changing the glide." + size="sm"> + + +