Skip to content

[drawer] Improve swipe dismiss drag performance#4867

Open
atomiks wants to merge 5 commits into
mui:masterfrom
atomiks:codex/drawer-swipe-render-perf
Open

[drawer] Improve swipe dismiss drag performance#4867
atomiks wants to merge 5 commits into
mui:masterfrom
atomiks:codex/drawer-swipe-render-perf

Conversation

@atomiks
Copy link
Copy Markdown
Contributor

@atomiks atomiks commented May 20, 2026

Fixes #4863

The swipe dismiss hook was updating React state for the drag offset on every move. This keeps the same gesture behavior, but applies transient drag styles directly to the dragged element so swipe movement no longer pays for an extra render per move event.

Root cause

Swipe movement was stored in React state, so every touch move scheduled a render even though the render only needed to update transient drag styles.

Changes

  • Store drag offset, the initial transform, and gesture bookkeeping in refs.
  • Apply the active drag transform and movement CSS variables imperatively during swipes.
  • Preserve snap-point overshoot damping when drag styles are updated imperatively.
  • Keep React state for the visible swipe lifecycle and dismiss state.

Benchmarks

Plain Vite production app, mobile viewport 390x844, same scripted down/up/down/up swipe.

Max input event is the largest EventDispatch duration. Max frame is the largest measured requestAnimationFrame interval during the gesture, so the frame budget column uses 16.67ms for 60 Hz.

Important to note: our docs shell affects perf measurements. For apples-to-apples comparison, a plain Vite app is more reliable.

In a manual Chrome Performance recording of the docs demo over the same four swipe movements, scripting dropped from 641ms to 205ms, reducing scripting share from 34.8% to 11.8%. The total selected range stayed similar because it includes gesture pacing, but the render-per-move work this PR targets is much lower.

Base Before

Docs shell (Actions sheet):

Screenshot 2026-05-21 at 6 10 11 pm

Vite app (Codex)

CPU slowdown Scripting Script share Max input event Max frame 60 Hz frame budget
1x 16.3ms 2.1% 1.90ms 9.30ms 56%
2x 16.5ms 2.1% 2.23ms 9.40ms 56%
4x 30.8ms 3.8% 4.85ms 9.40ms 56%
6x 48.6ms 5.9% 7.46ms 16.10ms 97%
10x 76.2ms 8.7% 14.03ms 32.40ms 194%
20x 185.6ms 18.2% 32.35ms 75.10ms 451%

Base After

Docs shell (Actions sheet):

Screenshot 2026-05-21 at 6 10 33 pm

Vite app (Codex)

CPU slowdown Scripting Script share Max input event Max frame 60 Hz frame budget
1x 13.8ms 1.8% 2.02ms 9.30ms 56%
2x 11.7ms 1.5% 2.19ms 9.30ms 56%
4x 24.7ms 3.1% 4.94ms 9.30ms 56%
6x 30.7ms 3.7% 6.92ms 9.40ms 56%
10x 60.2ms 6.9% 12.54ms 25.60ms 154%
20x 140.5ms 13.7% 31.34ms 76.00ms 456%

MUI

CPU slowdown Scripting Script share Max input event Max frame 60 Hz frame budget
1x 8.8ms 1.1% 1.87ms 9.30ms 56%
2x 8.9ms 1.1% 2.13ms 9.30ms 56%
4x 12.2ms 1.5% 4.30ms 9.30ms 56%
6x 19.3ms 2.4% 7.70ms 16.40ms 98%
10x 32.2ms 3.8% 12.08ms 25.90ms 155%
20x 80.7ms 8.3% 29.49ms 75.40ms 452%

We still have more scripting time than Material, but scripting remains a small share of the total trace time in the usable slowdown range. After this change, the max measured frame interval stays within the 60 Hz frame budget through 6x slowdown.

One caveat: iOS Low Power Mode can still feel laggy because this implementation does not use native scrolling for the swipe. Silk uses native scrolling, while Vaul doesn't. Keeping that path fully accelerated would require changing the implementation to use native scroll, but that lets desktop drawers move while the user scrolls, which this PR intentionally avoids for mouse usage, because it's awkward UX.

At 6x CPU slowdown, Base moves from a max frame at the 60 Hz edge (16.10ms) to comfortably under it (9.40ms). At 10x and 20x, all implementations miss the 60 Hz frame budget.

@atomiks atomiks added type: bug It doesn't behave as expected. component: drawer Changes related to the drawer component. performance labels May 20, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

commit: a33ac11

@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented May 20, 2026

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+404B(+0.09%) 🔺+106B(+0.07%)

Details of bundle changes

Performance

Total duration: 1,197.22 ms -126.45 ms(-9.6%) | Renders: 50 (+0) | Paint: 1,837.29 ms -175.09 ms(-8.7%)

Test Duration Renders
Select open (500 options) 45.50 ms ▼-12.06 ms(-20.9%) 14 (+0)

11 tests within noise — details


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

@netlify
Copy link
Copy Markdown

netlify Bot commented May 20, 2026

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit a33ac11
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a0eb3d496ff00000838148b
😎 Deploy Preview https://deploy-preview-4867--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 force-pushed the codex/drawer-swipe-render-perf branch from fda36c7 to d6138c8 Compare May 21, 2026 05:54
@atomiks atomiks force-pushed the codex/drawer-swipe-render-perf branch from 9dc68ab to 7b84441 Compare May 21, 2026 06:45
@atomiks atomiks force-pushed the codex/drawer-swipe-render-perf branch from 662e945 to a33ac11 Compare May 21, 2026 07:27
@atomiks atomiks marked this pull request as ready for review May 21, 2026 07:31
Copy link
Copy Markdown
Member

@flaviendelangle flaviendelangle left a comment

Choose a reason for hiding this comment

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

Will test on my phone later 👍

PR #4867 Review — [drawer] Improve swipe dismiss drag performance

Reviewed the actual PR diff (6 files) with five specialized agents. The refactor — moving drag offset/transform/direction out of React state into refs and applying drag styles imperatively via syncDragStyles, plus extracting the shared getSnapPointSwipeMovement helper — is sound and behavior-preserving. I ran the affected suites: useSwipeDismiss (15 pass, jsdom) and DrawerRoot (17 pass, chromium incl. the new overshoot test); ESLint on the changed drawer files is clean.

Two findings are worth acting on; the rest are polish.

Critical Issues

None. The math equivalence, the currentSwipeDirection ?? refref simplification, the three-writer style coordination ordering, and CLAUDE.md/AGENTS.md compliance all check out.

Important Issues

1. Missing finiteness guard in DrawerViewport's imperative writepackages/react/src/drawer/viewport/DrawerViewport.tsx:434

The new imperative block guards only details, but the adjacent block at line 457 guards details && Number.isFinite(details.deltaY). If details.deltaY is ever non-finite, getSnapPointSwipeMovement returns NaN and "NaNpx" is silently written to --drawer-swipe-movement-y (browser drops it → stale popup position, no error). The inconsistency strongly suggests an omission. Cheap fix: add && Number.isFinite(details.deltaY) to line 434.

2. syncDragStyles snapshot can leak transition: 'none' + frozen transform on uncommon teardown pathspackages/react/src/utils/useSwipeDismiss.ts:234-267

The [transition, transform] snapshot is taken on swipe start and only restored inside syncDragStyles(false). There's no cleanup effect for: element swapped mid-gesture, trackDrag/enabled toggled mid-swipe, or unmount mid-gesture. The early-return at line 236 only clears the snapshot when !swiping, so an active-swipe + transient-null-element leaves a stale snapshot tied to a dead node.

Caveat on severity: the actual Drawer consumer always uses trackDrag: true, doesn't swap the element mid-swipe, and calls syncDragStyles(false) before dismiss-unmount — so this is a robustness gap for the general-purpose hook, not an observed regression. Worth hardening (restore-or-clear unconditionally in the guard; consider a cleanup effect) but not blocking.

Suggestions

  • Defensive guard in the helperpackages/react/src/drawer/root/useDrawerSnapPoints.ts:17: getSnapPointSwipeMovement does no input validation and is one unguarded caller away from emitting NaN. Add an explicit : number return type (AGENTS.md convention) and a one-line doc comment on the square-root overshoot damping.
  • Comment rotpackages/react/src/utils/useSwipeDismiss.ts:1110-1114: the trackDrag JSDoc still says "update drag offsets in React state… avoid re-renders," but that state is gone. It now gates imperative style writes. Update it. Also the "freeze the element" comment at line 1021 should note the per-frame freeze actually happens imperatively in syncDragStyles, and the two transform strings must stay identical.
  • Under-documented coordination: add brief comments at DrawerViewport.tsx:433 and DrawerPopup.tsx:337 noting the imperative fast-path and the render-path fallback mirror each other and their guards must stay aligned.
  • Optional dedup: syncDragStyles and getDragStyles compute identical deltaX/deltaY/transform-string; a small shared helper would prevent the transform format from drifting between them.

Test Gaps (from pr-test-analyzer)

The two new tests are well-formed, but the central changed behavior is under-tested:

  1. No mid-swipe style.transform / transition: 'none' assertion — a regression that stops writing the imperative transform would pass all current tests.
  2. No start→move→cancel test asserting transform/transition are restored to pre-swipe inline values (the new reset test asserts restoration before any swipe starts, so the restore branch isn't exercised there).
  3. No direct unit test of getSnapPointSwipeMovement covering both branches — would cheaply lock the contract shared by the two consumers (suggest useDrawerSnapPoints.test.ts).

Strengths

  • Clean refactor — no orphaned comments referencing removed state, no false new comments.
  • getSnapPointSwipeMovement extraction correctly reproduces the old inline damping exactly (verified: Math.abs(nextOffset) === -nextOffset when negative), and the new chromium test pins the overshoot branch end-to-end (-Math.sqrt(150)).
  • The DrawerViewport/DrawerPopup dual-write is intentional (perf fast-path + render fallback) and the shared math is already lifted per AGENTS.md — should stay as-is.

Recommended Action

  1. Add && Number.isFinite(details.deltaY) at DrawerViewport.tsx:434 (quick, real).
  2. Fix the trackDrag JSDoc rot.
  3. Consider adding the three test gaps (esp. the mid-swipe transform assertion and the getSnapPointSwipeMovement unit test).
  4. Optionally harden the snapshot lifecycle and add the coordination comments.

Nothing here blocks merge — the perf goal is met and behavior is preserved.

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

Labels

component: drawer Changes related to the drawer component. performance type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[drawer] Swipe on low end devices has perf considerations

2 participants