[drawer] Improve swipe dismiss drag performance#4867
Conversation
commit: |
Bundle size
PerformanceTotal duration: 1,197.22 ms -126.45 ms(-9.6%) | Renders: 50 (+0) | Paint: 1,837.29 ms -175.09 ms(-8.7%)
11 tests within noise — 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. |
fda36c7 to
d6138c8
Compare
9dc68ab to
7b84441
Compare
662e945 to
a33ac11
Compare
flaviendelangle
left a comment
There was a problem hiding this comment.
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 ?? ref → ref 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 write — packages/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 paths — packages/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 helper —
packages/react/src/drawer/root/useDrawerSnapPoints.ts:17:getSnapPointSwipeMovementdoes no input validation and is one unguarded caller away from emittingNaN. Add an explicit: numberreturn type (AGENTS.md convention) and a one-line doc comment on the square-root overshoot damping. - Comment rot —
packages/react/src/utils/useSwipeDismiss.ts:1110-1114: thetrackDragJSDoc 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 insyncDragStyles, and the two transform strings must stay identical. - Under-documented coordination: add brief comments at
DrawerViewport.tsx:433andDrawerPopup.tsx:337noting the imperative fast-path and the render-path fallback mirror each other and their guards must stay aligned. - Optional dedup:
syncDragStylesandgetDragStylescompute identicaldeltaX/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:
- No mid-swipe
style.transform/transition: 'none'assertion — a regression that stops writing the imperative transform would pass all current tests. - 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).
- No direct unit test of
getSnapPointSwipeMovementcovering both branches — would cheaply lock the contract shared by the two consumers (suggestuseDrawerSnapPoints.test.ts).
Strengths
- Clean refactor — no orphaned comments referencing removed state, no false new comments.
getSnapPointSwipeMovementextraction correctly reproduces the old inline damping exactly (verified:Math.abs(nextOffset) === -nextOffsetwhen 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
- Add
&& Number.isFinite(details.deltaY)at DrawerViewport.tsx:434 (quick, real). - Fix the
trackDragJSDoc rot. - Consider adding the three test gaps (esp. the mid-swipe transform assertion and the
getSnapPointSwipeMovementunit test). - Optionally harden the snapshot lifecycle and add the coordination comments.
Nothing here blocks merge — the perf goal is met and behavior is preserved.
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
Benchmarks
Plain Vite production app, mobile viewport 390x844, same scripted down/up/down/up swipe.
Max input eventis the largestEventDispatchduration.Max frameis the largest measuredrequestAnimationFrameinterval 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):
Vite app (Codex)
Base After
Docs shell (Actions sheet):
Vite app (Codex)
MUI
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.