Repository navigation
[all components] Batch animation lookups for elements closing together - #5987
flaviendelangle wants to merge 6 commits into
Conversation
In Chromium, `element.getAnimations()` sorts every animation in the tree whenever the element has animations of its own, so N animated elements closing in the same frame cost N sorts of N animations. Lookups requested in the same task now run together in a microtask: elements are looked up on their own until one has animations, then the rest of its tree is served by one `getAnimations()` call on the root, grouped by target and without pseudo-element animations. An element looked up twice in the same frame reuses the first result. 500 Checkbox indicators with an exit transition: 2.35 s -> 0.15 s from uncheck to the last unmount (1,000: 20.8 s -> 0.28 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
commit: |
Bundle size
PerformanceTotal duration: 515.47 ms -8.67 ms(-1.7%) | Renders: 76 (+0) | Paint: 930.07 ms -7.80 ms(-0.8%) No significant changes — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
The shadow root and iframe tests now assert that both callbacks are still pending after the root lookup. Before, a root lookup that missed the second element would have completed it at once and still passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each root-served element in the tests had one animation, so grouping that kept only the last animation of a target would have passed. The new test finishes the later animation first and checks that the callback still waits for the other. The test helper now cancels every animation it starts, which StrictMode's second mount needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saves 6 B minified in every entry that uses the hook; gzip changes by -9 B to +1 B (main entry -1 B, Popover -1 B, Menu -6 B). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR reviewOne non-blocking performance regression at Bugs (1)1. 🟠 Avoid the root scan for two distinct targetsLocation: useAnimationsFinished.ts:45–47 if (root === animatedRoot && !animationsByTarget) {
animationsByTarget = new Map();
for (const animation of root.getAnimations!()) {After the first animated element, the second distinct target triggers a document scan even when it has no animations. For two targets, this cannot save a sort and can introduce another expensive one. Failure scenario: Two Checkbox indicators close together, only the first animates, and the page contains many unrelated CSS animations. With 2,000 animations, an interleaved Chromium probe measured median lookup time increasing from 20.2 ms to 40.8 ms. A real Checkbox reproducer confirmed that base avoids the document scan while head performs it. Fix: Preserve individual lookups and deduplication for batches with at most two distinct targets. Add coverage for mixed animated and nonanimated targets. VerdictApprove after nits — address the narrow small-batch performance regression. Validation: 299 Chromium tests and 9 jsdom tests passed using installed Vitest directly. Firefox/WebKit remain unverified locally; the combined run stopped on a missing Firefox executable. 🤖 Review generated with Codex |
182d13e to
3661c0d
Compare
useAnimationsFinished, which backsuseOpenChangeComplete, callselement.getAnimations()for each watched element one frame after an open state change. In Chromium, that call returns early when the element has no animations of its own. Otherwise it collects every animation in the tree, sorts them in composite order, and keeps the element's own. SeeElement::GetAnimationsInternalandDocumentAnimations::getAnimations. Blink sorts CSS animations and transitions by tree order withcompareDocumentPosition, which walks siblings. So N animated elements closing in the same frame cost N sorts of N animations. 500 checkbox indicators with an exit transition take 2.3 s to close, and 1,000 take 20 s.We hit this in while exploring a Tree experiment that lazily loaded 3,000 rows, each with a loading indicator built on
useOpenChangeCompleteand an infinite spinner animation, andgetAnimations()took 16.6 s of a 39 sexpandAll.Changes
getAnimations()call on the root of that element's tree, a document or a shadow root, serves the remaining elements of the tree, grouped by target. The grouping skips pseudo-element animations, sinceelement.getAnimations()doesn't return them.document.getAnimations()whenever several lookups are due would sort every animation on the page, for example when two popups without animations close on a page with running spinners.getAnimations()call and checkingpendingandplayState. Waiting on the current animations resolves at once when they have all finished, so the outcome is the same.flushBeforePaintand the lookup queue share a smallcreateMicrotaskQueuehelper.test/performance/tests/exit-animations.bench.tsx, which takes about 50 s.Performance
Headless Chromium 153 through
test/performance, with production React. The before and after builds ran in three interleaved rounds on the same machine. Values are medians of 9 iterations, or 6 for the 1,000-instance before runs of (a) and (b).onOpenChangeComplete(false)fires. In (a) and (e) it includes the 50 ms exit transition.ScriptDurationover the same window.In (a) to (d), a single uncheck unmounts N
Checkbox.Indicators.::afterIn (e), one Popover with an exit transition closes on a page with M unrelated infinite animations.
In (c) and (d), the new version makes the same
getAnimations()calls as before, and the unit tests assert that it never callsdocument.getAnimations()there. Timed alone, the added queue, cache andgetRootNode()work costs 0.1 to 0.2 ms per flush of 2,000 elements. The (d) row at N = 1,000 didn't reproduce in a separate sweep over N = 750 to 1,500 with 10 iterations each, which measured 34.4 ms before and 35.2 ms after at N = 1,000 and stayed within 1.2 ms at the other sizes. The large-N rows of (c) and (d) also move by up to 10 ms between sessions in both versions, mostly because of GC.Firefox and WebKit don't have the superlinear cost. On 2,000 animated sibling elements, one
document.getAnimations()call takes as long as 2,000 element lookups or less. Firefox measured 5 to 7 ms against 5 to 13 ms, and WebKit 8 to 10 ms against 11 to 38 ms. Firefox'sdocument.getAnimations()walks the DOM and takes 0.8 ms on a 50,000-node page. The root lookup only runs when a frame looks up a second element in a tree that already has an animated element, and the dedupe keeps a single closing popup from triggering it.Bundle size
Measured on the same machine with
pnpm size:snapshotbefore and after.@base-ui/reactgrows by 325 B parsed and 46 B gzipped. The dedupe accounts for about 16 B of the gzipped size.popoveris +108 B,dialog+95 B,checkbox+122 B andcollapsible+115 B gzipped.scroll-areagrows by 4 B gzipped.Semantics checked
I checked these in Chromium, Firefox and WebKit with standalone Playwright scripts, and the new Chromium tests cover them.
document.getAnimations()returns::beforeand::afteranimations witheffect.targetset to the originating element andeffect.pseudoElementset, andelement.getAnimations()doesn't. The grouping skips them.document.getAnimations()leaves out animations in shadow trees, andshadowRoot.getAnimations()returns them. The lookup useselement.getRootNode().getAnimations. jsdom andBASE_UI_ANIMATIONS_DISABLEDreturn before queueing, as before. When the root has nogetAnimations, for example with a mockedElement.prototype.getAnimations, each element gets its own lookup.waitForStartingStyleRemoved. The MutationObserver path queues its lookup the same way as the frame path. All mutation observer callbacks run before the queued microtask.isFiniteAnimationstill filters them out, but they count when deciding to switch to the root lookup, which is the Plus case.batch. Commit coalescing is unchanged.element.animate()animations.Tests
useAnimationsFinished.test.tsxcover several elements in one frame, with one root lookup, each element waiting on its own animations, an element with two animations served by the root lookup, and infinite animations ignored. They also cover pseudo-element exclusion, skipping the root lookup when elements have no animations or only pseudo-element ones, the single-element path, an element looked up twice, an abort, a shadow root and an iframe. Each test fails when its behavior is removed from the source.getAnimationsnow mockdocument.getAnimations()too.PopoverRoot.test.tsx > reopens immediately when re-hovering the trigger during a hover close transition, also fails onmasterwhen the whole file runs and passes on its own.🤖 Generated with Claude Code