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..669923a7f42bc --- /dev/null +++ b/apps/sandbox/src/app/(fullscreen)/pages/animated-layout-perf/page.tsx @@ -0,0 +1,557 @@ +// 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 { + 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'; +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'; +import type {AnimatedLayout} from '@astryxdesign/core/hooks'; + +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 (base == null) { + return 'no baseline'; + } + if (value == null) { + return 'baseline saved'; + } + if (base === 0) { + return value === 0 ? '±0%' : 'new'; + } + const change = ((value - base) / base) * 100; + return `${change >= 0 ? '+' : ''}${change.toFixed(0)}%`; +} + +/** The median: with an even count, the mean of the two middle values. */ +function median(values: number[]): number | null { + if (values.length === 0) { + return null; + } + const sorted = [...values].sort((a, b) => a - b); + const half = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[half] + : (sorted[half - 1] + sorted[half]) / 2; +} + +/** Saved baselines outlive a reload, so a build change can be compared. */ +const BASELINES_KEY = 'astryx-sandbox:animated-layout-perf:baselines'; + +function loadBaselines(): Record { + try { + const saved = window.localStorage.getItem(BASELINES_KEY); + return saved == null + ? {} + : (JSON.parse(saved) as Record); + } catch { + return {}; + } +} + +function storeBaselines(baselines: Record): void { + try { + window.localStorage.setItem(BASELINES_KEY, JSON.stringify(baselines)); + } catch { + // Storage unavailable (private mode): baselines last this session. + } +} + +const nextFrame = async (): Promise => + new Promise(resolve => { + requestAnimationFrame(resolve); + }); + +const wait = async (ms: number): Promise => + new Promise(resolve => { + 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 | undefined; + 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 moves the rows after it that are in view. + + + The page counts the rectangles read to find them, and times the work + until they start to move. + + + 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 [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); + const [baselines, setBaselines] = useState>({}); + // Read after hydration: the prerendered page has none. + useEffect(() => { + setBaselines(loadBaselines()); + }, []); + 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}:${glide}`; + const handle = glide === 'on' ? animatedLayout : undefined; + 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(() => toggleRef.current?.(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(() => toggleRef.current?.()); + // The layout walk runs in a microtask after the commit. + await Promise.resolve(); + await Promise.resolve(); + // The browser's own style and layout for the change, inside the clock + // in both modes (the walk forces it anyway), so with the handle minus + // without is the walk alone. Not a counted read. + void original.call(viewport); + 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); + } + // A stopped run publishes nothing: its totals would read as a full run's. + if (walks.length < TOGGLES) { + setMetrics(EMPTY_METRICS); + runningRef.current = false; + setIsRunning(false); + return; + } + 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 => { + const next = {...current, [key]: metrics}; + storeBaselines(next); + return next; + }); + }, [key, metrics]); + + // 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} + + ), + ); + } + return out; + }, [handle, middle, windowRange]); + + 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"> + + + + setGlide(value as 'on' | 'off')} + isDisabled={isRunning} + disabledMessage="Stop the active run before changing the glide." + 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. Run No handle for the same list to see what + the browser's own layout costs without the walk. A dropped + frame is a measured animation frame gap over 20 ms. A stopped + run reports nothing. Save a baseline (it is kept across reloads + in this browser), 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/',