Skip to content

fix: improve compositor transforms performance - #2

Merged
ryu-man merged 12 commits into
mainfrom
perf/compositor-transforms
Sep 19, 2026
Merged

ryu-man merged 12 commits into
mainfrom
perf/compositor-transforms

Conversation

@ryu-man

@ryu-man ryu-man commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

No description provided.

ryu-man and others added 12 commits September 18, 2026 21:15
`instanceof HTMLElement` needs globals that do not exist under SSR, and
fails for elements from another realm such as an iframe. Check for the
capability the call site actually needs instead: an inline style we can
write to.

Hoisted above the ref-counting entry points, which are about to need it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chromium promotes an element for an active transform animation, but
`animate()` drives transforms through custom properties, and those earn
no layer. Every frame of a dialog morph therefore repainted the whole
box, shadow included.

Set `will-change: translate, scale, rotate` while a transform channel is
in flight and restore the caller's own value once the last one ends. The
existing ref-count already says exactly when that is. A wired element
has non-`none` `translate`/`scale` regardless, so it was already a
stacking context and nothing observable changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chromium cannot composite a custom-property animation, and one such
property pins the whole effect — opacity included — to the main thread.
A dialog morph was a style recalc plus a paint on every frame.

When nothing else is composing on the element, evaluate the transform
template once per keyframe stop instead: each animated var becomes its
stop value, every other var its current computed value. What is left is
a plain transform keyframe the compositor accepts. The var keys are
removed from the effect, not merely shadowed, since leaving one behind
would pin it to the main thread again.

The var indirection exists so independent animations compose on one
element, so the fold is reversible and gives up the moment that matters.
`setKeyframes` swaps the values in place, keeping timing, playback state
and the finished promise, so the animation carries on from where it is.
The tracker demotes when another transform animation registers, when a
controller stops, and — via a style-attribute observer watching the
motion vars — when anything writes one inline. That last one is why
gestures, presets and consumer code keep composing without knowing folds
exist.

Two details worth naming. A caller's own `translate` is never folded
into, so wiring now records which templates are ours. And measurement
suppresses the vars with `!important`, which no longer reaches a folded
animation, so it reinstates the overridden template too and reads the
same resting box as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compositing is decided per effect, so one non-compositable property pins
everything animating alongside it. Props were merged on timing alone, so
`animate(el, { opacity: 0, height: 0 })` put both in one effect and the
opacity lost the compositor for a sibling's sake. The same sibling also
undid the transform fold, which had just finished removing the custom
properties from that effect.

Group by compositability as well as timing. Two effects with identical
timing start in the same frame, and `composite` is per property, so the
disjoint sets cannot clobber each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pull` re-read the element's box twice for the width and height the
caller had already measured. The spring write between the two reads
dirties style, so the second one was a forced layout on every pointer
move. Pass the rect through instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `will-change` hint lived in `registerTransformAnimation`, which only
`animate()` reaches. Every gesture drives `--motion-*` by hand instead,
so a dragged card with a shadow repainted on the main thread on every
pointer event with no layer of its own, and a reorder repainted every
row.

Add a ref-counted `hintTransformLayer()` beside the existing hint, so an
animation and a gesture can hold the same layer without either taking it
from the other, and hold it in all five gestures.

Scoped to the active gesture, not to attachment setup: a permanent hint
is a permanent layer, and a list would hold one per row. It outlives the
pointer where a spring keeps writing after release, and reorder hands
off to the settle FLIP, which takes its own hint through `animate()`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The unit tests cover the substitution; this covers whether `animate()`
engages the fold and gives it back. It has to run in the `client`
project, where `style.translate` and registered custom properties exist.

Asserts the shape the compositor requires — real transform properties in
the effect, no custom properties beside them — plus the three promises
the fold makes: it steps aside for a second animation, it still measures
the resting box, and it never touches a transform the caller set.
Compositing itself is not assertable; Chromium exposes no API for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fold costs about 840 B brotlied, which is what takes the morph off
the main thread. Everything else added this session is under 350 B
combined. Root goes to 20 kB and animate to 9 kB.

`flip` goes to 9 kB, which also clears a breach that predates this work:
it was 285 B over its 7 kB limit at dcba23b, so the gate has been red
for a while and was never the reason anything failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also drops a stray trailing newline prettier has been flagging in the
gesture-plumbing changeset since aa8ffee.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seeks a folded and a demoted animation to the same time and compares
rects, across a custom transform-origin and pre-existing motion vars.
Also covers stop() mid-flight, the end state and cleanup of a mixed
call, and an inline var write landing mid-fold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
package.json lost the storybook devDependencies already; bun.lock still
carried them, so a fresh install resolved a tree the manifest no longer
describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
graphify-out is 3.4 MB of regenerable output, cache included, and it is
rebuilt by `graphify update .`. Untracked and unignored, it also made
prettier fail over files nobody writes by hand, so `bun run lint` was
red for anyone who had run the tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ryu-man
ryu-man merged commit a5037ea into main Sep 19, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant