From 572f23bb6d901dc364a54f15cb7e05c06511bfb4 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 17 Aug 2026 20:12:11 +0200 Subject: [PATCH 1/2] Add renderer/style-recalc benchmarks, findings doc and layout profiler - dev/html/public/benchmarks: interactive benchmarks comparing renderer strategies (inline style, adopted stylesheet, registered CSS variables, WAAPI, CSS transitions, GSAP) in benign and hostile style-recalculation environments, plus a harness that isolates which selector/environment patterns turn a single style-attribute write into a full-page recalculation. - FINDINGS.md: measured results and methodology, including measurement pitfalls (vsync floor, rAF throttling in occluded tabs, tween semantics) and layout projection profiling notes. - dev/react/profile-layout.mjs: Playwright/CDP V8 sampling profiler for the layout stress examples, reporting projection-attributed JS time and per-function hotspots. - layout-stress-deep example: deep-tree (30x40) layout animation stress test exercising per-ancestor projection cost. Co-authored-by: Cursor --- dev/html/public/benchmarks/FINDINGS.md | 239 ++++++ .../public/benchmarks/renderer-bench-lib.js | 511 ++++++++++++ .../public/benchmarks/renderer-test-1.html | 119 +++ .../public/benchmarks/renderer-test-2.html | 212 +++++ .../public/benchmarks/renderer-test-3.html | 268 +++++++ .../benchmarks/style-recalc-harness.html | 736 ++++++++++++++++++ dev/react/profile-layout.mjs | 142 ++++ dev/react/src/App.tsx | 8 + dev/react/src/examples/layout-stress-deep.tsx | 57 ++ 9 files changed, 2292 insertions(+) create mode 100644 dev/html/public/benchmarks/FINDINGS.md create mode 100644 dev/html/public/benchmarks/renderer-bench-lib.js create mode 100644 dev/html/public/benchmarks/renderer-test-1.html create mode 100644 dev/html/public/benchmarks/renderer-test-2.html create mode 100644 dev/html/public/benchmarks/renderer-test-3.html create mode 100644 dev/html/public/benchmarks/style-recalc-harness.html create mode 100644 dev/react/profile-layout.mjs create mode 100644 dev/react/src/examples/layout-stress-deep.tsx diff --git a/dev/html/public/benchmarks/FINDINGS.md b/dev/html/public/benchmarks/FINDINGS.md new file mode 100644 index 0000000000..54a0688d5e --- /dev/null +++ b/dev/html/public/benchmarks/FINDINGS.md @@ -0,0 +1,239 @@ +# Renderer & Style Invalidation Findings + +Results from the renderer investigation (Aug 2026): can a new write mechanism +avoid the "massive style recalculation" failure mode of per-frame +`element.style` writes, and what should Motion's renderer(s) be? + +Benchmark pages (serve `dev/html` on port 8000): + +- `style-recalc-harness.html` — which environments make style writes expensive, + across write mechanisms (inline, CSS vars, registered vars, adopted + stylesheet). Supports `&flush` for vsync-independent style-cost timing. +- `renderer-test-1.html` — granularity: 5 paint values on 300 boxes, then 1. +- `renderer-test-2.html` — the same 5 values in a hostile invalidation + environment (`[style*=]` attribute selectors + descendant subtrees). +- `renderer-test-3.html` — transforms in the hostile environment: + inline vs var vs stylesheet, with/without `will-change`. + +Protocol everywhere: 4 runs per condition without reload, first run discarded +(JIT warmup), results averaged over runs 2–4. + +## What triggers the recalculation bomb + +Per-frame inline writes of **non-inherited standard properties are cheap by +default** — Blink/WebKit invalidation is precise. The bomb requires one of +these subscriptions (flush-timed style cost, 100 boxes × 50 descendants, +5 props/frame, embedded Chromium): + +| Environment | Inline writes | Sheet writes | Bomb? | +| --- | --- | --- | --- | +| plain | 0.36 ms/frame | — | no | +| 50 descendants/box (non-inherited props) | 0.47 | 1.09 | no | +| `:has()` keyed on classes | 0.30 | — | no | +| 20k-rule stylesheet (complex selectors, no `[style]`) | 1.08 | 1.53 | no | +| `[style*="…"]` selectors (1k rules) | **28.5** | **1.04** | yes | +| inherited property (`color`) + descendants | **27.8 ms frame** | 16.67 (fast path) | yes | +| unregistered `--var` + descendants (even unreferenced) | ~4 ms/frame hidden | — | at scale | +| `@container style(--p)` subscriptions in descendants | **22–28 ms frame** | **same** | yes, unavoidable | + +Notes: + +- `[style*=]` selectors are rare in hand-written CSS (mostly adblock cosmetic + filters) but the other triggers are mainstream: animating `color` on an + element with children, animating CSS variables, `@container style()` queries. +- `CSS.registerProperty({ inherits: false })` suppresses the var-inheritance + fan-out (~5.7× cheaper) but does **not** help with `[style*=]` (the + attribute still mutates) or `@container style()` (subscription is on the + value). +- `@container style()` bombs **every** write mechanism, including adopted + stylesheets and (by construction) WAAPI — the subscription is to the + computed value. Document as a user-facing footgun; no renderer fixes it. +- `transition: all` vs named transitions: the `all` keyword itself is ~free; + the cost is transition retargeting for any rule covering a JS-animated + property (~few ms/frame at 500 transitions). Amplifier, not a bomb. +- 16.67 ms readings are the vsync floor. Use `&flush` to measure real style + cost when under budget. + +## Renderer comparison + +### Test 1 — benign page (300 boxes, 5 paint props) + +Chrome & Safari: **all renderers identical** (paint-bound or vsync floor). +legacy ≈ styleEffect ≈ varEffect ≈ GSAP ≈ WAAPI in frame time. +Granularity shows only in render JS/frame (phase B, 1 of 5 values animating): +legacy 0.36–0.47 ms, styleEffect 0.06–0.10 ms (≈5×), varEffect 0.22–0.26 ms. + +### Test 2 — hostile page + +Real Chrome, 300 boxes × 80 descendants, 2k `[style*=]` rules: + +| Renderer | fps | mean frame | +| --- | --- | --- | +| WAAPI (element.animate) | **30.6** | **33 ms** | +| CSS transition | 26.3 | 38 ms | +| sheet (adopted stylesheet) | 26.4 | 40 ms | +| GSAP (inline writes) | 4.0 | 250 ms | +| styleEffect | 3.9 | 259 ms | +| legacy (re-apply all) | 3.7 | 267 ms | +| varEffect (registered vars, inline) | 2.8 | 354 ms | + +Real Safari, same scale: + +| Renderer | fps | mean frame | +| --- | --- | --- | +| CSS transition | 3.9 | 256 ms | +| WAAPI | **3.9** | **259 ms** | +| GSAP | 2.9 | 351 ms | +| styleEffect | 2.7 | 393 ms | +| legacy | 2.6 | 398 ms | +| sheet | 2.4 | **411 ms — no escape in WebKit** | +| varEffect | 1.9 | 588 ms | + +Key engine difference: Blink has a scoped fast path for CSSOM rule-declaration +mutation (sheet ≈ WAAPI); WebKit does not (sheet ≈ inline). WAAPI/transitions +are the only mechanisms that win or tie in **both** engines. + +### Test 3 — transforms in hostile env (embedded Chromium, 300 boxes) + +`will-change` is useless while recalc-bound (style/var ≈50 ms with or without). +Escape recalc first (sheet ~33 ms), then `will-change` removes paint → +locked 60 fps (16.67 ms). Order of operations matters: attribute-invalidation +→ paint → compositing. + +## Renderer decision + +1. **Default to WAAPI for everything keyframeable** — including + non-accelerated paint properties. Only strategy that never loses in either + engine. Zero main-thread render JS (0.000 in every run). Springs already + pregenerate keyframes. Measure retargeting cost (computed-style read forces + a flush) before shipping. +2. **styleEffect (inline writes) remains the frame-driven fallback** + (gestures, scroll, useTransform). Inline is ~2× *cheaper* than sheet + writes in benign environments (0.47 vs 1.09 ms flush) — the common case. +3. **No sheet renderer.** Its only value was capping the hostile case, and it + only does so in Blink. Not worth cross-engine complexity, cascade weirdness + (rule loses to inline styles), and DevTools opacity. +4. **varEffect (registered vars written inline) is dead.** Worst renderer in + both engines: pays attribute invalidation + var indirection. + `CSS.registerProperty({ inherits: false })` remains useful for + Motion-internal generated variables only — never re-register user vars + (changes inheritance semantics). + +## GSAP comparison (defensible claims) + +- Clean pages: parity (~27 ms both, Chrome; both 60 fps, Safari). +- Hostile CSS, Chrome: **7.5× faster frames** (33 vs 250 ms) — structural: + GSAP must mutate the style attribute from its ticker every frame. +- Hostile CSS, Safari: 1.4× (259 vs 351 ms). +- Moderate hostile scale: 1.4–2×. +- Structural claims: zero main-thread animation JS; compositor animations + survive main-thread jank (categorical, not a multiplier). +- Motion's write-path JS is ~0.3% of frame time under load in Chrome + (0.72–0.79 ms of ~260 ms). Caveat: WebKit charges invalidation to the + setter (~7 ms/frame at 1,500 values). Frame cost is browser style/paint + work triggered by writes — the leverage is the write mechanism, not JS. + +## Layout projection performance (Aug 2026 follow-up) + +Profiled `dev/react?example=layout-stress-transform` (1,513 projection +nodes, 1,008 animating) with a V8 sampling profiler via Playwright/CDP +(`dev/react/profile-layout.mjs`) and an interleaved A/B harness that +alternates baseline/optimized builds per boot (fresh Vite server per +build, two profile runs per boot, take the minimum). + +### What the "projection JS" actually is + +- Skipping only the `style.transform = ...` write dropped total sampled JS + from ~200 ms to ~94 ms per 2 s window — **the CSSOM setter (browser + string parse + style invalidation, charged to JS) is over half of all + "projection JS"**. This is the floor; no JS restructuring touches it. +- **CSS Typed OM is slower**, not faster: retained + `CSSTransformValue` + `attributeStyleMap.set` measured 291 ms vs 199 ms + total (Blink's Typed OM set path does spec-mandated normalization). +- **Individual `translate`/`scale` properties are slower too** (two setter + calls: 80.6 ms vs 56.4 ms self-time in `applyProjectionStyles`). +- **`matrix()` serialization is slower** (~85 vs ~62 ms self-time): it + always carries six full-precision floats, while the composed + `translate3d`/`scale` string omits identity segments and collapses to + `"none"` near rest. Parse cost tracks string content, not function + count. `DOMMatrix` has no other route into a style — `.toString()` is + this same string plus allocation, and `CSSMatrixComponent` rides the + Typed OM set path already measured slower. +- **Rounding transform values corrupts projection**: rounding + translate/scale in `buildProjectionTransform` broke measure/unproject + round-trips (measurements happen with the rounded transform in the DOM + but are unprojected with exact values), leaving sub-pixel residuals that + failed 22 Cypress layout tests. Reverted — don't retry. + +### Shipped optimizations (all layout e2e + unit tests green, React 18+19) + +1. **Memoized projection style writes**: `applyProjectionStyles` caches the + last rendered transform/transformOrigin/opacity/visibility and skips + redundant CSSOM writes. +2. **Projection-only renders**: per-frame projection renders no longer + re-write the element's full style set; full renders only when values + change. `renderStyles` skips transform/origin when projection owns them. +3. **Root render sweep**: nodes set a flag and the projection root + schedules one frame callback that sweeps the tree, instead of ~1,000 + `frame.render` schedulings per frame (`schedule` self-time 19.6 ms → 0). +4. Hoisted `isDisplayContents` to `setOptions`, delta-reuse in + `applyTransformsToTarget` when no user transforms, fused + `mixBoxInto` (mix + equality + copy in one pass), keepAlive fast path in + the frameloop, indexed loops in FlatTree/scale correctors. +5. On animation complete, a full render restores scale-corrected values + (borderRadius etc.) — required by the sweep change. +6. **Cumulative path transforms**: each ancestor's projection delta is an + axis-aligned affine map (p → a·p + b), so a node's full ancestor + correction composes in closed form from its parent's cached transform + (`updatePathTransform`, stamped per updateProjection sweep). Replaces + the per-node walk over the whole ancestor path (`applyTreeDeltas`) — + O(nodes × depth) → O(nodes). At depth 30 (1,201 nodes, + `layout-stress-deep`): 31.1 ms → 9.2 ms for the path work, calc-side + total ~37.5 → ~21 ms per 2 s. Break-even at shallow depth (~5), scales + with depth. Shared transitions (layoutId/resumingFrom) keep the legacy + walk: they interleave scroll offsets and ancestor `latestValues` + transforms whose origins depend on the box being projected, so they + don't compose into one per-layer map. Using `DOMMatrix` for this math + instead of plain records was considered and rejected: identical idea, + but every op crosses a C++ binding and `multiply()` allocates; the + specialized {a, b, scale} record is the same matrix without the + overhead (and the box {min,max} format itself was never the cost). + +### Results (interleaved A/B, median of per-boot minimums) + +- Whole-animation window: total sampled JS **−16%** (368 → 309 ms), + projection-attributed **−14.5%** (217 → 186 ms). Mid-animation windows: + −15–23% across three independent A/B rounds. +- Excluding the irreducible CSSOM write, the pure JS computation reduced + ~25–30%. The original ≥50% target is not reachable by JS restructuring: + ~60% of what profilers attribute to projection functions is the browser's + style write cost, and both alternative write mechanisms measured slower. +- Remaining JS hotspots (per 2 s, optimized): `applyProjectionStyles` + ~10 ms ex-write, `mixTargetDelta` 13 ms, `resolveTargetDelta` + + `calcProjection` ~22 ms, `JSAnimation.tick` 10 ms. Halving further means + fewer nodes doing per-frame math (e.g. WAAPI-driven pregenerated + projection keyframes) — an architectural project, not an optimization. + +### Layout measurement pitfalls + +- Single-run profile comparisons swing ±40% with thermals; only the + interleaved A/B (alternating builds per boot, min of repeated runs) + produced stable deltas. +- Window placement matters: mid-animation windows exclude the ease tail + where write-memoization wins; whole-animation windows include the + unoptimized React didUpdate burst. Report which one you measured. + +## Measurement pitfalls (repeat offenders) + +- **Vsync floor**: 16.67 ms means "under budget", not "equal". Use the + harness `&flush` metric or CDP `Performance.getMetrics` + (RecalcStyleDuration deltas) to unclamp. +- **rAF throttling**: background/occluded tabs throttle rAF to ~1–2 fps. + Force `Page.setWebLifecycleState { state: "active" }` via CDP, and verify + tick rate before trusting a run. +- **Tween semantics**: `gsap.to()` from current values created zero-change + tweens in alternating-phase benchmarks — use explicit endpoints + (`fromTo`/keyframes) everywhere or fast runs are fake. +- **Embedded Chromium inflates paint cost** (software raster). Relative + rankings held up in real browsers; absolute numbers did not. +- LoAF only reports frames ≥50 ms — useless for sub-budget costs. diff --git a/dev/html/public/benchmarks/renderer-bench-lib.js b/dev/html/public/benchmarks/renderer-bench-lib.js new file mode 100644 index 0000000000..100a738c3c --- /dev/null +++ b/dev/html/public/benchmarks/renderer-bench-lib.js @@ -0,0 +1,511 @@ +/** + * Shared library for the renderer benchmark pages + * (renderer-test-1/2/3.html). + * + * Provides: + * - Renderer strategies: legacy (old VisualElement full re-apply), + * style (styleEffect), var (varEffect, registered CSS properties), + * sheet (experimental: adopted stylesheet rule writes - never touches + * the element's style attribute after setup). + * - A phase runner implementing the protocol: 4 runs without reload, + * first run discarded as JIT/warmup. + * - Frame metering: wallclock frame stats + time spent in Motion's + * render step (the actual style write cost). + */ + +export const PAINT_PROPS = { + backgroundColor: ["rgb(255, 40, 40)", "rgb(40, 40, 255)"], + borderColor: ["rgb(40, 255, 40)", "rgb(255, 40, 255)"], + borderRadius: ["4px", "20px"], + boxShadow: ["0 0 2px rgb(255, 40, 40)", "0 0 14px rgb(40, 40, 255)"], + outlineColor: ["rgb(20, 20, 20)", "rgb(255, 255, 40)"], +} + +export function buildBoxes(container, n, descendants = 0) { + const parts = [] + let inner = "" + for (let j = 0; j < descendants; j++) { + inner += `x` + } + for (let i = 0; i < n; i++) { + parts.push(`
${inner}
`) + } + container.innerHTML = parts.join("") + return Array.from(container.querySelectorAll(".box")) +} + +/** ------------------------------------------------------------------ */ +/** Renderer strategies */ +/** ------------------------------------------------------------------ */ + +export function createStrategies(Motion) { + const { motionValue, styleEffect, varEffect, frame, cancelFrame } = Motion + + const makeValues = (props) => { + const values = {} + for (const prop in props) values[prop] = motionValue(props[prop][0]) + return values + } + + /** + * Old VisualElement renderer semantics: any value change schedules a + * render that re-applies EVERY bound style, including stagnant ones. + */ + const legacy = { + name: "legacy (re-apply all)", + bind(boxes, props) { + const all = [] + const subscriptions = [] + const renders = [] + + for (const box of boxes) { + const values = makeValues(props) + const keys = Object.keys(values) + + const render = () => { + for (const key of keys) { + box.style[key] = values[key].get() + } + } + renders.push(render) + + for (const key of keys) { + subscriptions.push( + values[key].on("change", () => frame.render(render)) + ) + } + + render() + all.push(values) + } + + return { + values: all, + cleanup() { + for (const unsub of subscriptions) unsub() + for (const render of renders) cancelFrame(render) + for (const box of boxes) box.removeAttribute("style") + }, + } + }, + } + + /** Granular per-value writes to element.style */ + const style = { + name: "styleEffect (granular)", + bind(boxes, props) { + const all = [] + const cleanups = [] + for (const box of boxes) { + const values = makeValues(props) + cleanups.push(styleEffect(box, values)) + all.push(values) + } + return { + values: all, + cleanup() { + for (const cleanup of cleanups) cleanup() + for (const box of boxes) box.removeAttribute("style") + }, + } + }, + } + + /** Registered custom properties (inherits: false), written to inline style */ + const varStrategy = { + name: "varEffect (registered vars)", + bind(boxes, props) { + const all = [] + const cleanups = [] + for (const box of boxes) { + const values = makeValues(props) + cleanups.push(varEffect(box, values)) + all.push(values) + } + return { + values: all, + cleanup() { + for (const cleanup of cleanups) cleanup() + for (const box of boxes) box.removeAttribute("style") + }, + } + }, + } + + /** + * Experimental: per-element rule in an adopted stylesheet. Values are + * written into the rule's declaration block, so the element's style + * attribute is never mutated after setup - this dodges [style*=...] + * attribute invalidation entirely (see style-recalc-harness). + */ + let sheetId = 0 + const sheet = { + name: "sheet (adopted stylesheet)", + bind(boxes, props) { + const all = [] + const subscriptions = [] + const renders = [] + const styleSheet = new CSSStyleSheet() + const keys = Object.keys(props) + + const src = [] + const ids = [] + for (let i = 0; i < boxes.length; i++) { + const id = sheetId++ + ids.push(id) + boxes[i].setAttribute("data-mb", id) + src.push(`[data-mb="${id}"] {}`) + } + styleSheet.replaceSync(src.join("\n")) + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + styleSheet, + ] + + const dash = (key) => + key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`) + + for (let i = 0; i < boxes.length; i++) { + const rule = styleSheet.cssRules[i] + const values = makeValues(props) + + for (const key of keys) { + const varName = `--mb${ids[i]}-${key}` + try { + CSS.registerProperty({ + name: varName, + syntax: "*", + inherits: false, + }) + } catch (e) {} + rule.style.setProperty(varName, values[key].get()) + rule.style.setProperty(dash(key), `var(${varName})`) + + const render = () => { + rule.style.setProperty(varName, values[key].get()) + } + renders.push(render) + subscriptions.push( + values[key].on("change", () => frame.render(render)) + ) + } + + all.push(values) + } + + return { + values: all, + cleanup() { + for (const unsub of subscriptions) unsub() + for (const render of renders) cancelFrame(render) + document.adoptedStyleSheets = + document.adoptedStyleSheets.filter( + (s) => s !== styleSheet + ) + for (const box of boxes) box.removeAttribute("data-mb") + }, + } + }, + } + + /** + * WAAPI (element.animate) on the main thread. Values are applied via + * the animation cascade origin, NOT the style attribute - so there is + * zero attribute mutation, before, during or after the animation. + * These paint properties are not compositable, so the animation still + * ticks on the main thread (style + paint per frame) like the others. + */ + const waapi = { + name: "WAAPI (element.animate)", + bind(boxes) { + const binding = { + boxes, + animations: [], + cleanup() { + for (const animation of binding.animations) { + animation.cancel() + } + binding.animations = [] + }, + } + return binding + }, + animate(binding, phaseProps, props, flip, duration) { + for (const animation of binding.animations) animation.cancel() + binding.animations = [] + + for (const box of binding.boxes) { + const keyframes = {} + for (const prop of phaseProps) { + const [a, b] = props[prop] + keyframes[prop] = flip ? [b, a] : [a, b] + } + binding.animations.push( + box.animate(keyframes, { + duration: duration * 1000, + easing: "linear", + fill: "forwards", + }) + ) + } + return Promise.all( + binding.animations.map((animation) => animation.finished) + ) + }, + } + + /** + * GSAP's JS renderer: its ticker writes inline styles every frame, + * so like legacy/styleEffect/varEffect it mutates the style attribute + * per frame and pays any attribute-selector invalidation cost. + */ + const gsapStrategy = { + name: "GSAP (inline writes)", + bind(boxes, props) { + for (const box of boxes) { + for (const key in props) box.style[key] = props[key][0] + } + return { + boxes, + cleanup() { + for (const box of boxes) { + window.gsap.killTweensOf(box) + box.removeAttribute("style") + } + }, + } + }, + animate(binding, phaseProps, props, flip, duration) { + const tweens = [] + for (const box of binding.boxes) { + /** + * fromTo with explicit endpoints, matching the keyframe + * semantics of the other strategies. A plain .to() would + * create zero-change tweens for any property already at + * its target (phases alternate direction), silently + * animating fewer values than the other renderers. + */ + const fromVars = {} + const toVars = { duration, ease: "none", overwrite: "auto" } + for (const prop of phaseProps) { + const [a, b] = props[prop] + fromVars[prop] = flip ? b : a + toVars[prop] = flip ? a : b + } + tweens.push(window.gsap.fromTo(box, fromVars, toVars)) + } + return Promise.all(tweens) + }, + } + + return { legacy, style, var: varStrategy, sheet, waapi, gsap: gsapStrategy } +} + +/** ------------------------------------------------------------------ */ +/** Metering */ +/** ------------------------------------------------------------------ */ + +export function createFrameMeter(Motion) { + const { frame, cancelFrame } = Motion + let deltas = [] + let renderMs = 0 + let renderStart = 0 + let rafId + let lastTs + + const preRender = () => { + renderStart = performance.now() + } + const postRender = () => { + renderMs += performance.now() - renderStart + } + + return { + start() { + deltas = [] + renderMs = 0 + lastTs = undefined + frame.preRender(preRender, true) + frame.postRender(postRender, true) + const tick = (ts) => { + if (lastTs !== undefined) deltas.push(ts - lastTs) + lastTs = ts + rafId = requestAnimationFrame(tick) + } + rafId = requestAnimationFrame(tick) + }, + stop() { + cancelAnimationFrame(rafId) + cancelFrame(preRender) + cancelFrame(postRender) + const sorted = [...deltas].sort((a, b) => a - b) + const frames = deltas.length || 1 + return { + frames: deltas.length, + mean: deltas.reduce((a, b) => a + b, 0) / frames, + p95: sorted[ + Math.min(sorted.length - 1, Math.floor(0.95 * frames)) + ], + max: sorted[sorted.length - 1] ?? 0, + renderMsPerFrame: renderMs / frames, + fps: 1000 / (deltas.reduce((a, b) => a + b, 0) / frames), + } + }, + } +} + +/** ------------------------------------------------------------------ */ +/** Runner */ +/** ------------------------------------------------------------------ */ + +/** + * Creates a benchmark driver. Phases are named prop subsets, e.g. + * { A: [all five], B: ["backgroundColor"] }. Exposes granular + * setup/runPhase/teardown for external (CDP) drivers, and runAll() + * implementing the standard protocol: per strategy, `runs` iterations + * of each phase without reload, first run discarded. + */ +export function createRunner({ + Motion, + boxes, + strategies, + props, + phases, + duration = 2, + runs = 4, + onStatus = () => {}, +}) { + const { animate } = Motion + const meter = createFrameMeter(Motion) + + let active = null + let direction = 0 + + async function setup(name) { + if (active) teardown() + direction = 0 + const strategy = strategies[name] + active = { name, strategy, binding: strategy.bind(boxes, props) } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + + async function runPhase(phaseName) { + const phaseProps = phases[phaseName] + const flip = direction++ % 2 === 1 + + meter.start() + if (active.strategy.animate) { + /** + * Strategy drives its own animation (e.g. CSS transitions), + * bypassing Motion's main-thread animation loop entirely. + */ + await active.strategy.animate( + active.binding, + phaseProps, + props, + flip, + duration + ) + } else { + const animations = [] + for (const values of active.binding.values) { + for (const prop of phaseProps) { + const [a, b] = props[prop] + animations.push( + animate(values[prop], flip ? [b, a] : [a, b], { + duration, + ease: "linear", + }) + ) + } + } + await Promise.all(animations) + } + const stats = meter.stop() + await new Promise((resolve) => setTimeout(resolve, 100)) + return stats + } + + function teardown() { + active?.binding.cleanup() + active = null + } + + async function runAll(names) { + const results = [] + for (const name of names) { + await setup(name) + const perPhase = {} + for (const phaseName in phases) perPhase[phaseName] = [] + + for (let r = 0; r < runs; r++) { + for (const phaseName in phases) { + onStatus( + `${strategies[name].name} — run ${ + r + 1 + }/${runs}, phase ${phaseName}` + ) + perPhase[phaseName].push(await runPhase(phaseName)) + } + } + teardown() + + const summary = { name: strategies[name].name } + for (const phaseName in phases) { + /** Discard first run (JIT warmup) */ + const warm = perPhase[phaseName].slice(1) + const avg = (key) => + warm.reduce((a, s) => a + s[key], 0) / warm.length + summary[phaseName] = { + mean: avg("mean"), + p95: avg("p95"), + fps: avg("fps"), + renderMsPerFrame: avg("renderMsPerFrame"), + runs: perPhase[phaseName], + } + } + results.push(summary) + } + return results + } + + return { setup, runPhase, teardown, runAll } +} + +/** ------------------------------------------------------------------ */ +/** Reporting */ +/** ------------------------------------------------------------------ */ + +export function renderResultsTable(el, results, phases) { + const phaseNames = Object.keys(phases) + let html = `renderer` + for (const p of phaseNames) { + html += `${p}: fps${p}: mean frame (ms)${p}: p95 (ms)${p}: render JS (ms/frame)` + } + html += `` + for (const r of results) { + html += `${r.name}` + for (const p of phaseNames) { + html += `${r[p].fps.toFixed(1)}${r[p].mean.toFixed( + 2 + )}${r[p].p95.toFixed(2)}${r[ + p + ].renderMsPerFrame.toFixed(3)}` + } + html += `` + } + el.innerHTML = html +} + +export const panelCSS = ` + #panel { + position: fixed; top: 0; left: 0; right: 0; + background: rgba(0, 0, 0, 0.88); color: #fff; + padding: 10px 14px; z-index: 1000; + font-family: system-ui, sans-serif; font-size: 12px; + max-height: 40vh; overflow: auto; + } + #panel table { border-collapse: collapse; margin-top: 6px; } + #panel td, #panel th { border: 1px solid #555; padding: 2px 8px; text-align: right; } + #panel th:first-child, #panel td:first-child { text-align: left; } +` diff --git a/dev/html/public/benchmarks/renderer-test-1.html b/dev/html/public/benchmarks/renderer-test-1.html new file mode 100644 index 0000000000..a367927ec2 --- /dev/null +++ b/dev/html/public/benchmarks/renderer-test-1.html @@ -0,0 +1,119 @@ + + + + + + +
+
Loading…
+
+
+
+ + + + + diff --git a/dev/html/public/benchmarks/renderer-test-2.html b/dev/html/public/benchmarks/renderer-test-2.html new file mode 100644 index 0000000000..ad0f46487f --- /dev/null +++ b/dev/html/public/benchmarks/renderer-test-2.html @@ -0,0 +1,212 @@ + + + + + + +
+
Loading…
+
+
+
+ + + + + diff --git a/dev/html/public/benchmarks/renderer-test-3.html b/dev/html/public/benchmarks/renderer-test-3.html new file mode 100644 index 0000000000..59e6a09cc7 --- /dev/null +++ b/dev/html/public/benchmarks/renderer-test-3.html @@ -0,0 +1,268 @@ + + + + + + +
+
Loading…
+
+
+
+ + + + diff --git a/dev/html/public/benchmarks/style-recalc-harness.html b/dev/html/public/benchmarks/style-recalc-harness.html new file mode 100644 index 0000000000..9302aa7767 --- /dev/null +++ b/dev/html/public/benchmarks/style-recalc-harness.html @@ -0,0 +1,736 @@ + + + + + + +
+
Loading…
+
+
+
+ + + + diff --git a/dev/react/profile-layout.mjs b/dev/react/profile-layout.mjs new file mode 100644 index 0000000000..eaba12373a --- /dev/null +++ b/dev/react/profile-layout.mjs @@ -0,0 +1,142 @@ +/** + * Profiles a layout-animation stress example. + * + * Loads dev/react at ?example=, starts Motion's recordStats() plus the + * V8 sampling profiler, clicks to trigger the layout animation, then reports: + * - frame rate + projection metrics (from recordStats) + * - total sampled JS ms and per-frame JS ms + * - self-time hotspots grouped by function + * - time attributed to projection source vs everything else + * + * Usage: node profile-layout.mjs [example] [profileMs] [port] + * e.g. node profile-layout.mjs layout-stress 4000 9990 + */ +import { chromium } from "playwright" +import { writeFileSync } from "fs" + +const example = process.argv[2] || "layout-stress" +const profileMs = +(process.argv[3] || 4000) +const port = +(process.argv[4] || 9990) +/** ms to wait after the click before profiling (skips the didUpdate burst) */ +const skipMs = process.argv[5] === undefined ? 600 : +process.argv[5] + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } }) +await page.goto(`http://localhost:${port}/?example=${example}`) +await page.waitForFunction(() => typeof window.recordStats === "function") +/** Let the initial mount settle */ +await page.waitForTimeout(500) + +const client = await page.context().newCDPSession(page) +await client.send("Profiler.enable") +await client.send("Profiler.setSamplingInterval", { interval: 100 }) + +/** + * recordStats() returns a report function on newer builds. On older + * builds it returns undefined - stats are then omitted from the output + * and only the V8 profile is reported. + */ +const startStats = () => + page.evaluate(() => { + window.__report = window.recordStats() + }) + +/** Trigger the layout animation */ +if (skipMs === 0) { + await startStats() + await client.send("Profiler.start") + await page.mouse.click(500, 500) +} else { + await page.mouse.click(500, 500) + + /** + * Skip the one-off measure/didUpdate burst at animation start so the + * profile captures pure steady-state per-frame work. + */ + await page.waitForTimeout(skipMs) + + await startStats() + await client.send("Profiler.start") +} + +await page.waitForTimeout(profileMs) + +const { profile } = await client.send("Profiler.stop") +const stats = await page.evaluate(() => + typeof window.__report === "function" ? window.__report() : null +) + +/** ------------------------------------------------------------------ */ +/** Aggregate the profile */ +/** ------------------------------------------------------------------ */ + +const nodesById = new Map() +for (const node of profile.nodes) nodesById.set(node.id, node) + +const selfTime = new Map() // key -> µs +let totalSampled = 0 +const totalWallUs = profile.endTime - profile.startTime + +for (let i = 0; i < profile.samples.length; i++) { + const delta = profile.timeDeltas[i] ?? 0 + const node = nodesById.get(profile.samples[i]) + if (!node) continue + const { functionName, url, lineNumber } = node.callFrame + if (functionName === "(idle)" || functionName === "(program)") continue + totalSampled += delta + const key = `${functionName || "(anonymous)"} @ ${url + .split("/") + .slice(-1)} :${lineNumber}` + selfTime.set(key, (selfTime.get(key) || 0) + delta) +} + +const sorted = [...selfTime.entries()].sort((a, b) => b[1] - a[1]) + +/** + * Projection functions live in the framer-motion dep bundle; identify them by + * name since the bundle is a single file. Names taken from + * motion-dom/src/projection/** and geometry utils. + */ +const projectionNames = + /projection|targetdelta|dirtynodes|calcprojection|calcrelative|calcboxdelta|calcaxisdelta|removeboxtransforms|applyboxdelta|applyaxisdelta|applytreedelta|boxequal|axisequal|aspectratio|translateaxis|transformbox|scalepoint|applypointdelta|hasscale|has2dtranslate|hastransform|buildprojectiontransform|measure|measurepagebox|measureviewportbox|convertboundingbox|updateprojection|updatelayout|updatesnapshot|notifylayoutupdate|resetskewandrotation|mixaxisdelta|mixbox|mixaxis|snapshot/i + +let projectionUs = 0 +for (const [key, us] of selfTime) { + if (projectionNames.test(key)) projectionUs += us +} + +const out = { + example, + profileMs, + fps: stats ? stats.frameloop.rate : undefined, + projectionMetricsPerFrame: stats + ? { + nodes: stats.layoutProjection.nodes, + calculatedTargetDeltas: + stats.layoutProjection.calculatedTargetDeltas, + calculatedProjections: + stats.layoutProjection.calculatedProjections, + } + : undefined, + animations: stats ? stats.animations : undefined, + js: { + totalSampledMs: +(totalSampled / 1000).toFixed(1), + wallMs: +(totalWallUs / 1000).toFixed(1), + jsShareOfWall: +((totalSampled / totalWallUs) * 100).toFixed(1) + "%", + projectionAttributedMs: +(projectionUs / 1000).toFixed(1), + projectionShareOfJS: + +((projectionUs / totalSampled) * 100).toFixed(1) + "%", + }, + top30: sorted + .slice(0, 30) + .map(([key, us]) => `${(us / 1000).toFixed(1).padStart(7)}ms ${key}`), +} + +console.log(JSON.stringify(out, null, 2)) +writeFileSync( + `profile-${example}.json`, + JSON.stringify({ out, profile }, null, 0) +) +console.log(`\nraw profile written to profile-${example}.json`) + +await browser.close() diff --git a/dev/react/src/App.tsx b/dev/react/src/App.tsx index 0974ee6be4..6f93e56239 100644 --- a/dev/react/src/App.tsx +++ b/dev/react/src/App.tsx @@ -1,5 +1,13 @@ +import { recordStats } from "framer-motion/debug" import { StrictMode } from "react" +/** + * Expose Motion's stats recorder for benchmarking/automation. + * Usage from devtools or a driver: + * window.__report = window.recordStats(); ...; window.__report() + */ +;(window as any).recordStats = recordStats + const examples = import.meta.glob("./examples/*.tsx", { eager: true, import: "App", diff --git a/dev/react/src/examples/layout-stress-deep.tsx b/dev/react/src/examples/layout-stress-deep.tsx new file mode 100644 index 0000000000..448a4719a7 --- /dev/null +++ b/dev/react/src/examples/layout-stress-deep.tsx @@ -0,0 +1,57 @@ +import { motion, MotionConfig } from "framer-motion" +import * as React from "react" +import { useState } from "react" + +/** + * Deep-tree layout stress test: many chains of deeply nested projecting + * nodes. Exercises the per-node ancestor-path cost of projection + * calculation (O(nodes × depth) with per-node path walks vs O(nodes) + * with cumulative path transforms). + */ + +const DEPTH = 30 +const CHAINS = 40 + +function Chain({ depth }: { depth: number }) { + if (depth === 0) return null + return ( + + + + ) +} + +export const App = () => { + const [expanded, setExpanded] = useState(false) + + return ( + +
setExpanded(!expanded)} + > + {Array.from({ length: CHAINS }, (_, i) => ( + + ))} +
+
+ ) +} From cf3d17df54aab0703d16ca93237eede0eb966a15 Mon Sep 17 00:00:00 2001 From: Matt Perry Date: Mon, 17 Aug 2026 20:50:56 +0200 Subject: [PATCH 2/2] Make var renderer strategy self-contained Review finding: the benchmarks destructured varEffect from window.Motion, but that only exists on an experimental branch - on main the var strategies threw at bind time. The lib now falls back to a standalone implementation of the same mechanism (per-value registered custom property with inherits: false, style points at var(--name) once, per-frame writes touch only the custom property), preferring Motion.varEffect when present. Co-authored-by: Cursor --- .../public/benchmarks/renderer-bench-lib.js | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/dev/html/public/benchmarks/renderer-bench-lib.js b/dev/html/public/benchmarks/renderer-bench-lib.js index 100a738c3c..90b38fb54d 100644 --- a/dev/html/public/benchmarks/renderer-bench-lib.js +++ b/dev/html/public/benchmarks/renderer-bench-lib.js @@ -4,7 +4,7 @@ * * Provides: * - Renderer strategies: legacy (old VisualElement full re-apply), - * style (styleEffect), var (varEffect, registered CSS properties), + * style (styleEffect), var (registered CSS properties), * sheet (experimental: adopted stylesheet rule writes - never touches * the element's style attribute after setup). * - A phase runner implementing the protocol: 4 runs without reload, @@ -39,7 +39,80 @@ export function buildBoxes(container, n, descendants = 0) { /** ------------------------------------------------------------------ */ export function createStrategies(Motion) { - const { motionValue, styleEffect, varEffect, frame, cancelFrame } = Motion + const { motionValue, styleEffect, frame, cancelFrame } = Motion + + /** + * Registered-custom-property renderer. Uses Motion's varEffect when + * available (experimental branches); otherwise falls back to a + * standalone implementation of the same mechanism: register a + * per-value custom property with inherits: false, point the real + * style at var(--name) once, then write only the custom property + * per frame. + */ + const TRANSFORM_KEYS = new Set(["x", "y", "scale", "rotate"]) + let benchVarId = 0 + const registerVar = () => { + const name = `--bench-var-${benchVarId++}` + try { + CSS.registerProperty({ name, syntax: "*", inherits: false }) + } catch {} + return name + } + const camelToDash = (key) => key.replace(/[A-Z]/g, "-$&").toLowerCase() + + const varEffect = + Motion.varEffect || + ((element, values) => { + const unsubs = [] + const renders = [] + const transformKeys = [] + + for (const key in values) { + if (TRANSFORM_KEYS.has(key)) { + transformKeys.push(key) + continue + } + + const value = values[key] + const name = registerVar() + element.style.setProperty(camelToDash(key), `var(${name})`) + const render = () => + element.style.setProperty(name, String(value.get())) + renders.push(render) + unsubs.push(value.on("change", () => frame.render(render))) + render() + } + + if (transformKeys.length) { + const name = registerVar() + element.style.setProperty("transform", `var(${name})`) + const get = (key, fallback) => + values[key] ? values[key].get() : fallback + const render = () => + element.style.setProperty( + name, + `translate(${get("x", 0)}px, ${get( + "y", + 0 + )}px) scale(${get("scale", 1)}) rotate(${get( + "rotate", + 0 + )}deg)` + ) + renders.push(render) + for (const key of transformKeys) { + unsubs.push( + values[key].on("change", () => frame.render(render)) + ) + } + render() + } + + return () => { + for (const unsub of unsubs) unsub() + for (const render of renders) cancelFrame(render) + } + }) const makeValues = (props) => { const values = {}