Skip to content

[all components] Reduce animation completion work - #5535

Merged
atomiks merged 5 commits into
mui:masterfrom
atomiks:codex/batch-animation-completions
Aug 20, 2026
Merged

[all components] Reduce animation completion work#5535
atomiks merged 5 commits into
mui:masterfrom
atomiks:codex/batch-animation-completions

Conversation

@atomiks

@atomiks atomiks commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #5481

When multiple animation watchers finish together, each currently runs its own flushSync. Queue callbacks that become ready in the same microtask and flush them once before paint, while continuing to respect aborted callbacks. Avatar.Image now only watches exit animations because its completion callback only performs work on exit.

Performance

Synthetic Chromium benchmark with a 1 ms CSS animation:

Scenario Before After
100 Checkbox indicators: commits after uncheck 101 2
500 Avatar images entering: worst frame gap ~542 ms ~25 ms

The shared queue retains one callback record until the next microtask. Avatar enter animations no longer call getAnimations().

@atomiks atomiks added type: bug It doesn't behave as expected. component: checkbox Changes related to the checkbox component. component: avatar Changes related to the avatar component. labels Aug 19, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 19, 2026

Copy link
Copy Markdown

commit: b1766ea

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 19, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+333B(+0.07%) 🔺+84B(+0.06%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Aug 19, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit b1766ea
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a869c7bc92e6a0008308ef3
😎 Deploy Preview https://deploy-preview-5535--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.

@atomiks atomiks added scope: all components Widespread work has an impact on almost all components. performance and removed component: checkbox Changes related to the checkbox component. component: avatar Changes related to the avatar component. type: bug It doesn't behave as expected. labels Aug 19, 2026
@atomiks
atomiks marked this pull request as ready for review August 19, 2026 09:25
@atomiks

atomiks commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

The review finished without producing a report.


🤖 Review generated with Claude Code · Opus 5 (High) · medium review depth · 12 turns · 1m19s · $0.53 · run

@atomiks
atomiks force-pushed the codex/batch-animation-completions branch from 00b3f4f to b1766ea Compare August 20, 2026 06:19

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The direction make a lot of sense to me
I think Simplification 2 is over-engineered and can be dismissed.

PR review

One narrow behavior regression is reproducible, but nothing merge-blocking. A temporary Chromium test passed on the base commit and failed on the PR head. The existing focused tests, TypeScript, ESLint, and Prettier checks otherwise passed.

Bugs (1)

1. 🟠 Batching can detach an indicator rechecked by a sibling cleanup

Location: packages/react/src/checkbox/indicator/CheckboxIndicator.tsx:39

useOpenChangeComplete({
  batch: true,
  open: rendered,
  ref: indicatorRef,
  onComplete() {
    if (!rendered) {
      setMounted(false);
    }
  },
});

The callbacks run before any of their state updates commit. If removing indicator A invokes a forwarded ref callback or layout-effect cleanup that rechecks controlled indicator B, B has already queued setMounted(false) using its stale closed state.

Previously, A committed first. Its cleanup reopened B before B's completion ran, aborting B's pending unmount. The same mechanism affects Radio and the Menu checkbox and radio indicators.

Failure scenario: Two indicators finish closing together. A's ref cleanup rechecks B. On this branch, B's forwarded ref receives null and then the element again, restarting its enter transition and potentially losing focus. The same Chromium test keeps B continuously mounted on the base commit.

Fix: Keep per-callback commits for components whose unmount runs consumer code, or redesign batching so later callbacks are revalidated after earlier commit effects. Add this ref-cleanup case as a regression test.

Simplifications (2)

1. 🟡 Skip enter-side watchers for batched indicators

Location: packages/react/src/checkbox/indicator/CheckboxIndicator.tsx:39

useOpenChangeComplete({
  batch: true,
  open: rendered,
  // ...
  onComplete() {
    if (!rendered) {
      setMounted(false);
    }
  },
});

When an indicator opens, its completion handler cannot do anything. The hook still waits for starting styles, calls getAnimations() on every element, creates promises, and schedules a no-op flush. The Avatar change already avoids this work for the same reason.

Failure scenario: Programmatically checking or selecting 100 animated items performs 100 unnecessary animation queries. This leaves the enter-side cost that the PR's Avatar benchmark identifies as expensive.

Fix: Pass enabled: !rendered, with the equivalent closed-state condition at the other five indicator call sites. Add an assertion that getAnimations() is not called while entering.

2. 🟡 Keep the opt-in queue out of non-batched component bundles

Location: packages/react/src/internals/useAnimationsFinished.ts:8

let pendingCallbacks: Array<() => void> | null = null;

function flushBeforePaint(fn: () => void) {
  // ...
}

Only six indicator callers enable batching, but the queue and its branch now ship with every consumer of useAnimationsFinished. A filtered bundle measurement showed the non-batched Dialog entry increasing from 62,514 to 62,744 parsed bytes and from 20,932 to 21,017 gzip bytes.

Failure scenario: Applications importing Dialog, Popover, or another default-path component pay for queue code they cannot use.

Fix: Extract the scheduler into an indicator-only module or hook and pass it into the shared animation waiter. Keep the default path free of a static import of the batching implementation.

Verdict

Approve after nits - one narrow batching regression and two efficiency cleanups remain.


🤖 Review generated with Codex

@atomiks

atomiks commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author
  1. 🟠 Batching can detach an indicator rechecked by a sibling cleanup

This seems to be unreachable in any realistic scenario and the failure mode doesn't seem severe, so ignoring here

Applied simplification No. 1 before the review already

@atomiks
atomiks merged commit 838b084 into mui:master Aug 20, 2026
22 of 23 checks passed
@atomiks
atomiks deleted the codex/batch-animation-completions branch August 20, 2026 07:11
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.

[checkbox] Unchecking multiple controlled checkboxes causes one React commit per Indicator

2 participants