fix(DismissableLayer): keep body pointer-events locked when nested layer closes (#2674)#2678
Conversation
…yer closes (#2674) The body `pointer-events: none` lock was managed with a `watchEffect` that read `context.layersWithOutsidePointerEventsDisabled.size`. Because `context` is `reactive()`, that read made the effect re-run whenever any layer was added or removed from the set. When a nested layer (e.g. a DropdownMenu inside a Dialog) closed, the still-open ancestor layer's effect re-ran and its cleanup prematurely restored the body's original `pointer-events`, stripping the lock while the Dialog remained open. Switch to `watch` with explicit sources (`layerElement`, `disableOutsidePointerEvents`) so the effect only re-runs on those changes, mirroring Radix's `useEffect` dependency list. Reads of the set size inside the callback no longer create reactive dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commit: |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughSwitched DismissableLayer from a ChangesNested Dismissable Layer Pointer-Events Management
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/core/src/DismissableLayer/DismissableLayer.test.ts (1)
51-82: ⚡ Quick winAdd a regression test for
disableOutsidePointerEventsprop toggling while mounted.This suite covers mount/unmount layering well, but it does not assert
true → falseprop transitions on an already-mounted layer. That path is where stale layer-membership regressions are easiest to miss.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/DismissableLayer/DismissableLayer.test.ts` around lines 51 - 82, Add a regression test in DismissableLayer.test.ts that verifies toggling the disableOutsidePointerEvents prop from true to false while a layer is still mounted correctly updates layer membership and restores document.body.style.pointerEvents; use the existing mountNested helper to create an outer/inner pair, set disableOutsidePointerEvents on the mounted DismissableLayer instance(s), toggle it from true→false (for example by mutating the prop/reactive ref used to render the component), await tick/sleep, and assert that pointerEvents is adjusted accordingly (remains 'none' while an outer layer is open, and becomes '' after the last layer closes). Ensure you reference the DismissableLayer component and the mountNested/outerOpen/innerOpen helpers so the test targets the same mounting logic that previously covered mount/unmount paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/core/src/DismissableLayer/DismissableLayer.vue`:
- Around line 162-170: The cleanup watcher restores body.pointerEvents but fails
to remove the layer from the tracking sets, so update the onCleanup/invalidation
handlers (the block using onCleanup and the mirror block handling layerElement)
to also delete the relevant element from
context.layersWithOutsidePointerEventsDisabled and context.layers when
disableOutsidePointerEvents becomes false or the watcher invalidates; locate
references to disableOutsidePointerEvents, context.originalBodyPointerEvents,
context.layersWithOutsidePointerEventsDisabled, layers and layerElement and
ensure you call context.layersWithOutsidePointerEventsDisabled.delete(element)
and context.layers.delete(element) (or the corresponding removal) as part of the
same cleanup path so last-layer restore semantics remain correct.
---
Nitpick comments:
In `@packages/core/src/DismissableLayer/DismissableLayer.test.ts`:
- Around line 51-82: Add a regression test in DismissableLayer.test.ts that
verifies toggling the disableOutsidePointerEvents prop from true to false while
a layer is still mounted correctly updates layer membership and restores
document.body.style.pointerEvents; use the existing mountNested helper to create
an outer/inner pair, set disableOutsidePointerEvents on the mounted
DismissableLayer instance(s), toggle it from true→false (for example by mutating
the prop/reactive ref used to render the component), await tick/sleep, and
assert that pointerEvents is adjusted accordingly (remains 'none' while an outer
layer is open, and becomes '' after the last layer closes). Ensure you reference
the DismissableLayer component and the mountNested/outerOpen/innerOpen helpers
so the test targets the same mounting logic that previously covered
mount/unmount paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 7549bdbd-3d07-4018-9f54-e07f524a8228
📒 Files selected for processing (2)
packages/core/src/DismissableLayer/DismissableLayer.test.tspackages/core/src/DismissableLayer/DismissableLayer.vue
…p toggle (#2674) Addresses CodeRabbit review: the watch cleanup restored body pointer-events but never removed the layer from `layersWithOutsidePointerEventsDisabled`, leaving a stale entry when `disableOutsidePointerEvents` toggled `true -> false` while the layer stayed mounted (e.g. a modal Menu closing, which binds the prop to `menuContext.open`). A stale entry makes a later `size === 0` check false, so the body would fail to re-lock. Move the deletion into the watch cleanup and restore the body when the set is empty *after* deletion, making the restore independent of cleanup ordering. Add regression tests for the toggle-while-mounted and restore/re-lock paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @coderabbitai — both points addressed in ba7cd63:
|
|
Tip For best results, initiate chat on the files or code changes.
(ノ◕ヮ◕)ノ*:・゚✧ 🐰 If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
Closes #2674
Problem
Opening a
DropdownMenu(or any modal layer) inside aDialogand then closing the menu strips thepointer-events: nonestyle from<body>, even though the Dialog is still open. This breaks the modal pointer-event lock while the dialog remains visible.Root cause
DismissableLayermanages the body lock in awatchEffect:Because
contextisreactive(), readingcontext.layersWithOutsidePointerEventsDisabled.sizeregisters a reactive dependency, so the effect re-runs whenever any layer is added/removed from the set — across all mounted layers.When the nested menu closes, the still-open Dialog's effect re-runs, and its cleanup sees
size === 1and restores the body's originalpointer-events, removing the lock prematurely.Radix React avoids this by using
useEffectwith a fixed dependency list ([node, ownerDocument, disableOutsidePointerEvents, context]) — it does not re-run on set mutations.Fix
Convert the
watchEffectto awatchwith explicit sources (layerElement,disableOutsidePointerEvents). Reads of the set size inside the callback no longer create reactive dependencies, so the effect only re-runs when its real inputs change — matching the React behavior.Tests
Added two tests under
DismissableLayer.test.ts:bodypointer-events: nonewhile the outer layer stays openbodypointer-eventsAll existing Dialog / Menu / Popover / DropdownMenu / AlertDialog tests pass.
🤖 Generated with Claude Code
Summary by CodeRabbit
Tests
Refactor