Skip to content

[all components] Batch animation lookups for elements closing together - #5987

Open
flaviendelangle wants to merge 6 commits into
mui:masterfrom
flaviendelangle:perf/batched-get-animations
Open

flaviendelangle wants to merge 6 commits into
mui:masterfrom
flaviendelangle:perf/batched-get-animations

Conversation

@flaviendelangle

@flaviendelangle flaviendelangle commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

useAnimationsFinished, which backs useOpenChangeComplete, calls element.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. See Element::GetAnimationsInternal and DocumentAnimations::getAnimations. Blink sorts CSS animations and transitions by tree order with compareDocumentPosition, 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 useOpenChangeComplete and an infinite spinner animation, and getAnimations() took 16.6 s of a 39 s expandAll.

Changes

  • Lookups requested in the same task run together in a microtask. Each element is still looked up on its own until one of them has animations. After that, one 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, since element.getAnimations() doesn't return them.
  • Looking up elements on their own first keeps Chromium's early return for elements without animations. Calling 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.
  • An element looked up twice in the same frame reuses the first result. A closing popup is looked up twice, once for its popup part and once for the root that unmounts it.
  • When an animation is canceled, the hook runs the lookup again instead of making its own getAnimations() call and checking pending and playState. Waiting on the current animations resolves at once when they have all finished, so the outcome is the same.
  • flushBeforePaint and the lookup queue share a small createMicrotaskQueue helper.
  • Adds 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).

  • "Complete" is the time from the state change until every completion has run, taken when the last indicator unmounts or when onOpenChangeComplete(false) fires. In (a) and (e) it includes the 50 ms exit transition.
  • "Script" is CDP ScriptDuration over the same window.

In (a) to (d), a single uncheck unmounts N Checkbox.Indicators.

Scenario N Complete before Complete after Script before Script after
(a) exit transition on the indicator 1 39 ms 40 ms 0.6 ms 0.6 ms
100 77 ms 59 ms 19 ms 5.1 ms
500 2,351 ms 154 ms 2,223 ms 29 ms
1,000 20,787 ms 281 ms 20,585 ms 79 ms
2,000 not run, over the 120 s timeout 749 ms not run 286 ms
(b) infinite animation on the indicator 1 6.7 ms 7.1 ms 0.5 ms 0.5 ms
100 34 ms 18 ms 18 ms 3.8 ms
500 2,265 ms 75 ms 2,214 ms 26 ms
1,000 20,192 ms 185 ms 20,060 ms 84 ms
2,000 not run 508 ms not run 277 ms
(c) no animation 1 1.2 ms 1.3 ms 0.4 ms 0.4 ms
100 14 ms 13 ms 3.2 ms 3.3 ms
500 59 ms 58 ms 15.4 ms 15.3 ms
1,000 121 ms 119 ms 31.9 ms 31.9 ms
2,000 261 ms 264 ms 64.3 ms 67.8 ms
(d) infinite animation on ::after 1 1.2 ms 1.2 ms 0.4 ms 0.5 ms
100 14 ms 14 ms 3.4 ms 3.3 ms
500 65 ms 65 ms 16.8 ms 16.6 ms
1,000 133 ms 142 ms 34.5 ms 42.1 ms
2,000 319 ms 298 ms 71.9 ms 74.7 ms

In (e), one Popover with an exit transition closes on a page with M unrelated infinite animations.

M Complete before Complete after Script before Script after
0 51 ms 51 ms 0.3 ms 0.3 ms
500 71 ms 69 ms 4.9 ms 2.8 ms
2,000 195 ms 145 ms 73 ms 37 ms

In (c) and (d), the new version makes the same getAnimations() calls as before, and the unit tests assert that it never calls document.getAnimations() there. Timed alone, the added queue, cache and getRootNode() 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's document.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:snapshot before and after.

  • @base-ui/react grows by 325 B parsed and 46 B gzipped. The dedupe accounts for about 16 B of the gzipped size.
  • Single-component entries that use the hook grow by 270 to 278 B parsed and 92 to 122 B gzipped. popover is +108 B, dialog +95 B, checkbox +122 B and collapsible +115 B gzipped. scroll-area grows 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.

  • Pseudo-elements. document.getAnimations() returns ::before and ::after animations with effect.target set to the originating element and effect.pseudoElement set, and element.getAnimations() doesn't. The grouping skips them.
  • Shadow DOM. document.getAnimations() leaves out animations in shadow trees, and shadowRoot.getAnimations() returns them. The lookup uses element.getRootNode().
  • Iframes. An element in an iframe has the iframe document as its root, which gets its own lookup.
  • Disconnected elements. They're always looked up on their own, since their root isn't a document or a shadow root.
  • Environments without getAnimations. jsdom and BASE_UI_ANIMATIONS_DISABLED return before queueing, as before. When the root has no getAnimations, for example with a mocked Element.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.
  • Aborts. The completion handlers check the signal as before, and a new test covers an abort among batched lookups.
  • Retries. A canceled animation queues a new lookup, and the existing replacement and no-replacement tests pass.
  • Infinite animations. isFiniteAnimation still filters them out, but they count when deciding to switch to the root lookup, which is the Plus case.
  • batch. Commit coalescing is unchanged.
  • Animation sources. The root lookup returns CSS transitions, CSS animations and element.animate() animations.

Tests

  • New Chromium tests in useAnimationsFinished.test.tsx cover 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.
  • The multi-element tests that mock getAnimations now mock document.getAnimations() too.
  • In Chromium, the Checkbox, Radio, Menu, Select, Combobox, Popover, Dialog, Collapsible, Accordion, Field, Tabs, Toast, Tooltip and internals suites pass, as do ScrollArea, NavigationMenu, Avatar, PreviewCard, Drawer, AlertDialog, ContextMenu and Menubar. The one failure, PopoverRoot.test.tsx > reopens immediately when re-hovering the trigger during a hover close transition, also fails on master when the whole file runs and passes on its own.
  • The same suites pass in jsdom.

🤖 Generated with Claude Code

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>
@flaviendelangle flaviendelangle added performance scope: all components Widespread work has an impact on almost all components. labels Oct 14, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

commit: 3661c0d

@code-infra-dashboard

code-infra-dashboard Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+378B(+0.07%) 🔺+66B(+0.04%)

Details of bundle changes

Performance

Total 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.

@netlify

netlify Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 3661c0d
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6ad1d7c8162be3b2565d604f
😎 Deploy Preview https://deploy-preview-5987--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

flaviendelangle and others added 3 commits October 14, 2026 11:44
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>
@flaviendelangle
flaviendelangle marked this pull request as ready for review October 14, 2026 11:07
@atomiks

atomiks commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

PR review

One non-blocking performance regression at 9d9b9a5442. No merge-blocking correctness findings.

Bugs (1)

1. 🟠 Avoid the root scan for two distinct targets

Location: 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.

Verdict

Approve 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

@atomiks
atomiks force-pushed the perf/batched-get-animations branch from 182d13e to 3661c0d Compare October 16, 2026 07:52

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance scope: all components Widespread work has an impact on almost all components.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants