Skip to content

fix(BottomSheet): a sheet opened inside another sheet's content drags to dismiss on its own - #8820

Merged
imdreamrunner merged 1 commit into
mainfrom
fix/bottom-sheet-nested-sheet-drag
Oct 30, 2026
Merged

imdreamrunner merged 1 commit into
mainfrom
fix/bottom-sheet-nested-sheet-drag

Conversation

@imdreamrunner

@imdreamrunner imdreamrunner commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

fix(core): a sheet hosting another sheet in its content leaves that sheet's gestures alone

Core: packages/core/src/BottomSheet/useSheetGestures.ts and two test files. One commit on main.

User impact

Anyone on a touch device using a BottomSheet opened from inside another open sheet's content — component:BottomSheet FR5's stack, as the Tokenizer's list-value sheet opens over Lab MobilePowerSearch's value step, or a detail sheet opens from a row inside a sheet. Dragging the top sheet down by its grab handle does not dismiss it. Where the covered host allows swipe dismissal (the stacking stories), the host dismisses instead — and takes the top sheet with it (FR8); where the host does not (a purpose="form" step), the top sheet springs back and nothing closes. Restoring it gives the gesture to the sheet it was made on: the top sheet follows the finger and dismisses; the covered host does not move.

Problem and solution fit

Why does it occur? A sheet rendered in another sheet's content renders its <dialog> as a DOM descendant of the host's body, so the pointer events of a drag on the nested sheet's handle bubble through the host body's gesture handlers (useSheetGestures): the host's onPointerDown arms a body drag — its scroll chain from the handle up to its body is at the top — and on the first move promotes it, calling beginDrag, which takes pointer capture on the host's body. Capture moves to the host, the nested handle gets lostpointercapture and cancels its own drag (the hook treats that as the drag losing its grip), and the rest of the gesture drives the host. The native touchstart listener on the host's body arms the same way for a touch inside the nested sheet.

Why does it harm the task? The visible, top sheet is the one the person is dragging; a gesture that moves the sheet behind it — a sheet FR6 says is blocked — or moves nothing is a sheet that cannot be dismissed the way every other sheet can.

Why does that harm matter? FR7 gives Escape, a scrim tap, a swipe and a platform close request to the top sheet of a stack only; the swipe was the one path that still reached a covered sheet, through DOM bubbling the stack never saw.

The fix is one predicate at the two points where the host arms a gesture: isInsideNestedLayer(target, body) walks from the event target up to the body and answers true when a <dialog> lies between — a nested sheet's, or a Dialog's opened from the content. The host's pointer arming (handleBodyPointerDown) and touch arming (the body's touchstart) then do nothing with that gesture, so the host neither drags nor takes capture, and the nested sheet's own handlers run as they do for a standalone sheet.

Expected behavior and authority

Smallest restoration

useSheetGestures.ts: a new exported isInsideNestedLayer (12 lines) and a guard in handleBodyPointerDown and in the body's onTouchStart. No prop, visual, default or interaction change for a sheet with no layer in its content; no change to the handle, pinch, scroll-handoff or capture logic.

Evidence

Chromium 156 headless shell via Playwright, iPhone profile 390×844, a real touch drag through CDP Input.dispatchTouchEvent (start, ten moves of 42 px, end) from the top sheet's grab handle; static Storybook builds of main (before) and this head (after). Sampled per move: the covered host's and the top sheet's translateY.

  • Reproduction before the change, lab-mobilepowersearch--touch-flow, Add filter → Assignee → tokenizer trigger (the tokenizer sheet over the value step): host/top per move 42/42, 84/42, 126/33, 168/18, 210/9, 252/1, 294/0 … 420/0 — the top sheet moves once, loses capture and springs back while the covered value step tracks the finger; at release both dialogs remain (the step is purpose="form", so it springs back). Instrumented: the top handle sees pointerdown, gotpointercapture, one pointermove, then lostpointercapture. Frames were captured stacked, mid-drag (top sheet at rest) and after release (both open). A drag from the top sheet's body, by contrast, does dismiss it (the native touch path promotes the top sheet first).
  • Control before the change, core-bottomsheetstacking--row-owned-detail (Lisbon over Places): the same handle drag dismisses both sheets — the covered host dismissed and the nested one left with it.
  • Control before the change, lab-mobiletokenizer--touch-flow standalone: the handle drag dismisses the sheet, as expected.
  • Result after the change, same Lab path: 0/42, 0/84, 0/126 … 0/420 — the top sheet follows the finger, the host does not move; at release only the value step remains. Frames were captured stacked, mid-drag (top sheet 210 px down over the still-placed step) and after release (tokenizer sheet gone). Stacking control: only Lisbon closes, Places stays. Standalone tokenizer: unchanged.
  • Representative unchanged path: the whole BottomSheet suite (handle drags, body drags, snap points, scroll handoff, pinch, context menu, switcher) passes unchanged.
  • Regression test or other mutation-sensitive proof: BottomSheet.test.tsx "a sheet opened from inside another sheet's content drags to dismiss by its own handle, and the covered host neither drags nor takes the pointer (FR6, FR7)" — an inner sheet rendered in an outer's content, a handle drag on the inner: the inner's onOpenChange(false) fires, the outer's does not, only the inner handle takes pointer capture, the outer panel keeps its resting transform. On main it fails: the outer's onOpenChange(false) fires (the host dismissed). useSheetGestures.test.ts "leaves a touch that starts inside a nested <dialog> to that layer" pins the touch path: a native touch pull-down that starts inside a <dialog> in the body is neither prevented nor tracked, while the same pull from the body's own content promotes. With the touchstart guard reverted it fails (1 failed, 443 passed). The same file also covers the predicate itself (own content, nested dialog, the sheet's own dialog above the body).

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split: none.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label (isInsideNestedLayer is a module export for its unit test, not a package export).
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run packages/core/src/BottomSheet — 10 files, 444 tests pass (the three new ones included); the stacked-drag test fails on main, and the nested-touch test fails with the touchstart guard reverted.
  • pnpm -F @astryxdesign/core typecheck — exit 0; strict lint on the three touched files — 0 findings; prettier and git diff --check clean.
  • Real browser: the Chromium before/after runs above (iPhone profile, CDP touch).

Changeset: [fix] patch for @astryxdesign/core.

@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 30, 2026 8:32pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
⏳ Queued (for maintainers only) Oct 30, 2026, 8:25 PM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 28, 2026
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Previews ready: Storybook and Sandbox were built with this PR's docsite. CI evidence will be added when available.

View Storybook for this PR · View Sandbox for this PR

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

One test is needed before this can be approved; the fix itself works: dragging a nested sheet's grabber now dismisses only that sheet, and the sheet beneath stays open. The new guard in the native touchstart handler (useSheetGestures.ts:1500) is untested: the regression test fires pointer events only, so reverting that line keeps the BottomSheet suite green although a real touch drag then dismisses the covered sheet again. Please add a test in useSheetGestures.test.ts, using the existing touchOn helper, where the touch starts inside a <dialog> appended to the body and expects no preventDefault and no drag.

[Automated review]

if (!touch) {
// content while the finger is still down (see onTouchMove). A touch
// inside a layer this sheet hosts is that layer's, never tracked here.
if (!touch || isInsideNestedLayer(event.target, body)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No test reaches this guard: the new regression test uses drag(), which fires pointer events only. With this line reverted to if (!touch) {, all 443 BottomSheet tests still pass, while a real touch swipe on a nested sheet's grabber in Chromium drags and dismisses the host again. A test in useSheetGestures.test.ts using the touchOn helper, with the touch starting inside a <dialog> appended to the body (expect defaultPrevented false and isDragging false), would catch it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recommending approval: dragging a sheet opened from inside another sheet's content now moves and dismisses only that sheet, by touch or pointer, and the sheet beneath it stays put. Each new guard is pinned by a test that fails when that guard is removed. Optionally, the changeset could also mention that a pull inside a Dialog opened from a sheet no longer drags the sheet beneath it, since the same check now covers that case.

[Automated review]

@imdreamrunner
imdreamrunner enabled auto-merge (squash) October 29, 2026 16:11
…heet's gestures alone

A BottomSheet opened from inside another sheet's content renders its dialog as
a descendant of the host's body, so a drag on the nested sheet's handle bubbled
into the covered host's body gesture handlers: the host armed a body drag on
the pointerdown, promoted it on the first move and took pointer capture, which
cancelled the nested handle's drag (lostpointercapture) and dragged the covered
host instead — a sheet FR6 says is blocked, by a swipe FR7 gives only to the
top sheet. The host's pointer and touch arming now ignores a gesture whose
target sits inside a <dialog> between it and the body.
@imdreamrunner
imdreamrunner force-pushed the fix/bottom-sheet-nested-sheet-drag branch from 219e873 to 278ae76 Compare October 30, 2026 20:25
@imdreamrunner
imdreamrunner merged commit d73908f into main Oct 30, 2026
98 of 103 checks passed
@github-actions
github-actions Bot deleted the fix/bottom-sheet-nested-sheet-drag branch October 31, 2026 06:09
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 1, 2026
…ayer's touches to it, on the recognizers

Main's meta#8820 kept a host sheet's pointer path off a gesture that starts
inside a <dialog> rendered in its body (a sheet or a Dialog opened from its
content). With the sheet's drag on the gesture recognizers, the body's pans
refuse such a touch (isInsideNestedLayer, now scoped to the body); the
handle's touches are the sheet's own. The nested-sheet test drives touches on
measured boxes; a pull from a nested <dialog> fails without the guard.

Also moves a release test off a tie: 150 px in 100 ms projected exactly onto
the midpoint between the 200 px detent and gone, where float rounding of the
event times decided the outcome; 160 px projects clearly past it.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 1, 2026
…ayer's touches to it, on the recognizers

Main's meta#8820 kept a host sheet's pointer path off a gesture that starts
inside a <dialog> rendered in its body (a sheet or a Dialog opened from its
content). With the sheet's drag on the gesture recognizers, the body's pans
refuse such a touch (isInsideNestedLayer, now scoped to the body); the
handle's touches are the sheet's own. The nested-sheet test drives touches on
measured boxes; a pull from a nested <dialog> fails without the guard.

Also moves a release test off a tie: 150 px in 100 ms projected exactly onto
the midpoint between the 200 px detent and gone, where float rounding of the
event times decided the outcome; 160 px projects clearly past it.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 1, 2026
…ayer's touches to it, on the recognizers

Main's meta#8820 kept a host sheet's pointer path off a gesture that starts
inside a <dialog> rendered in its body (a sheet or a Dialog opened from its
content). With the sheet's drag on the gesture recognizers, the body's pans
refuse such a touch (isInsideNestedLayer, now scoped to the body); the
handle's touches are the sheet's own. The nested-sheet test drives touches on
measured boxes; a pull from a nested <dialog> fails without the guard.

Also moves a release test off a tie: 150 px in 100 ms projected exactly onto
the midpoint between the 200 px detent and gone, where float rounding of the
event times decided the outcome; 160 px projects clearly past it.

This branch was successfully deployed

1 active deployment
Preview — 278ae763 Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant