Skip to content

test(MobileNav): a native close of the drawer releases the page lock - #8119

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c14-mobilenav-scroll-lock
Nov 3, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c14-mobilenav-scroll-lock

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

#8098 moved MobileNav onto the shared scroll lock. This pull request adds one guard its tests do not cover.

Maintainer impact

A <form method="dialog"> submit, or a close() through a ref, closes MobileNav's dialog before the app's isOpen follows. Nothing pins that the page lock still releases on that path. A lock held through the slide-out and released by the delayed close would leave the page locked: a native close never starts that close's timer.

Intended invariant

No behavior change. component:MobileNav FR3 (current): "While open, the page's root clips its overflow and keeps its scrollbar gutter; closing releases both." The release must hold when the dialog closes natively first.

Change

One unit case in MobileNavCloseEdgeCases.test.tsx, "releases the page when the dialog closed natively before isOpen turned false". It stubs a classic scrollbar, as MobileNavScrollbarGutter.test.tsx does, so the lock holds the gutter. While the drawer is open, the page is locked (whatever value the lock writes) and the gutter reads stable. Its dialog then closes natively, isOpen turns false, and both the root's overflow and its gutter are released. Test only, so no changeset.

Evidence

  • Failure before the change, on two committed controls (not for landing):
    • a MobileNav whose lock is held through the slide-out and released only by the delayed close (63f3e16) fails on the overflow: expected 'hidden' to be '';
    • useScrollLock without its gutter release (a46263f) fails on the gutter: expected 'stable' to be ''.
  • Success after the change: at this head, on main's shared lock, the case passes. Before fix(useScrollLock): keep the page where it is while an overlay is open #8098, MobileNav's own claim passed it too.
  • Product/runtime behavior verified unchanged: no source file changes.

Scope

  • No intended public API, product behavior, visual direction, or policy change.
  • Product changes discovered during the work were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 0a17209 (on main 2188aa9), after a frozen install (exit 0):

  • pnpm review:pre reports no findings; check:repo passes; the core type check exits 0; strict lint and Prettier are clean on the changed file.
  • MobileNav units 90/90, the new case included; the full test:ui run 19,550/19,550 (770 files).
  • storybook:build exits 0; MobileNav's three *.a11y.chromium.spec.ts contracts pass, 6/6.
  • test:node: 86 packages/cli files hit their 30 s timeout under box load (load 43-56 on 166 cores). The node project runs nothing this pull request changes: its one changed file is a jsdom test that only the ui project runs, so the node run's inputs are byte-identical to main's.
  • The two controls fail as named above.

@vercel

vercel Bot commented Oct 24, 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 Nov 3, 2026 6:35pm UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 6:37 PM UTC

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

@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.

This pull request is on hold for an owner decision. It carries #8064's MobileNav close behavior, which blocks every tap until the slide-out ends and returns focus only then, and no current spec decides that behavior; an engineering owner needs to approve or reject it by commenting /approve-spec <full head SHA> on this pull request, and as an engineering owner you may record that yourself. Whatever the decision, the following still need fixing, each with a regression test:

  • On a page styled html, body { height: 100% } with body { overflow-x: hidden }, opening a Dialog now jumps the page to the top and leaves it there after close, because the root's overflow: hidden stops <body>'s overflow from propagating to the viewport.
  • The touch guard starts its walk from event.target, which a document listener sees as the shadow host, so pans inside a scroller in a shadow root are cancelled; starting from event.composedPath()[0] fixes it.
  • The MobileNav fix targets iOS Safari, so it needs an iOS Safari run on this head showing that a drag on the open drawer leaves the page still while the drawer's list still scrolls.
  • The mobile-nav-shared-scroll-lock changeset says a pull-to-refresh control sees the lock, but no such control is part of this change.

[Automated review]


// Taken before the root's overflow hides the scrollbar.
const gutter = holdScrollbarGutter(document.documentElement);
const releaseRootStyle = claimRootScrollStyle({overflow: 'hidden'});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

On a page styled html, body { height: 100% } with body { overflow-x: hidden }, <body>'s overflow propagates to the viewport only while the root's overflow is visible. This claim stops that, so <body> becomes its own 100%-tall scroller and the viewport offset clamps to 0. Releasing the claim does not bring the offset back. In Chromium a Dialog on such a page moved scrollY 600 → 0 and left it at 0 after close, where main restores 600. Detecting that case (root overflow visible, <body>'s computed overflow not visible) and claiming <body>, or saving and restoring the offset, would keep the page where it is.

return;
}
// The browser picks the scroller once per gesture; so does the guard.
verdict = isPanAbsorbedBeforePage(event.target, dx, dy)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This listener sits on document, where event.target for a touch inside a shadow root is retargeted to the shadow host. The walk therefore never reaches a scroller inside the shadow tree, and the pan is cancelled. In Chromium an 80 px swipe moved a shadow-root scroller 0 px, against 76 px for a light-DOM scroller in the same open Dialog. Starting from event.composedPath()[0], or walking composedPath(), would let those scrollers take the pan.

'@astryxdesign/core': patch
---

[fix] MobileNav locks the page through the shared scroll lock (`useScrollLock`) while it is open and until its slide-out ends. On iOS a drag on the open drawer no longer scrolls the page behind it, and other overlays and the pull-to-refresh control see the lock.

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 pull-to-refresh control exists in this change or tree, and subscribeScrollLock has no production subscriber, so "and the pull-to-refresh control see the lock" promises something users will not find. Dropping that clause keeps the note accurate.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 0a17209

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
MobileNavToggle 46.1 kB (0) · 9.8 kB CSS (0) — — · ✓ · ✗
MobileNav 52.5 kB (0) · 10.4 kB CSS (0) — — · ✓ · ✓

Package size: no published package changed; no new dependencies.

API changes: none in this pull request

Committed API report behind this build: core 2 rows; run pnpm api:report.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…corded on the restacked heads (iOS 26.2 simulator, Chromium, Android emulator with Chromium 158)
@vjeux
vjeux force-pushed the uikit/c14-mobilenav-scroll-lock branch from c110de2 to 62fcf7d Compare October 25, 2026 04:48
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 25, 2026
…rag with the backdrop blur off for capture, and a blur-on still

@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.

This pull request is on hold for an engineering owner's decision. The draft spec:AST-094 it adds is the only record behind the experimental gesture and motion subpaths and the theme motion roles in this diff, so an engineering owner needs to approve or reject it by commenting /approve-spec with this pull request's full head SHA; as an engineering owner, you may record that decision yourself. Whatever the decision, the following need fixing:

  • The diff carries the whole open stack under #8098 (about 34,000 authored lines), so land those pull requests first and rebase until this one holds only its 96-line MobileNav commit.
  • The stable press controller (pressFeedback.ts) now turns off text selection and the touch callout under every touch press but ships under an [experimental] changeset, so keep the lock to the experimental press tracker or release it as a stable change.
  • @astryxdesign/core/motion/reducedMotion.stylex is a new stable export outside the experimental boundary, and the theme template documents a platform adaptation condition that defineTheme rejects.
  • In spec:AST-094, FR8's 0.5 s Reduce Motion cross-fade conflicts with the current 200 ms rule, FR4 says touch physics are not themable while this diff adds theme motion roles, and FR7 cites a spec that does not exist; three changesets also misdescribe what this head ships.

[Automated review]

@@ -283,6 +301,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This lock now runs for every touch or pen press on every usePressFeedback surface (Button, Link, Switch, Tab, Item and 26 more), writing user-select, -webkit-user-select and -webkit-touch-callout as none until the lift. That is a stable runtime change, which spec:AST-017 FR22 keeps out of an [experimental] changeset, and on iOS it should also hide the link callout on every Link. Keeping the lock to the experimental press tracker and recognizers with preventsTextSelection would match the changeset.

Comment thread packages/cli/assets/theme.template.ts Outdated
Comment on lines +361 to +362
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

defineTheme has no platform condition (CONDITION_AXES is width, pointer, contrast and motion), and when: {platform: 'ios'} throws "… is not supported". spec:AST-012 FR2 closes the vocabulary, so this sentence should go back to "primary-pointer precision, contrast preference, and motion preference".

Comment thread scripts/sync-exports.js Outdated
@@ -177,6 +190,13 @@ const STATIC_EXPORTS = {
types: './dist/utils/interactionOverlay.stylex.d.ts',
default: './dist/utils/interactionOverlay.stylex.js',
},
// Proposed; pending owner review (spec:AST-094 OQ4): the Reduce Motion

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

spec:AST-094 has only OQ1 and OQ2, and this subpath ships with no @experimental marker, so once released it is a stable export (spec:AST-017 FR22; FR27 asks for experimental exports and names for CSS and tokens). Exporting these constants from motion/experimental, or holding the export until an owner approves it, keeps it inside the boundary.

Comment on lines +108 to +110
cross-fades over 0.5 s on the ease-in-out curve; a sheet, a full-screen
modal or an alert fades on its own presentation spring and drops its travel
and scale; menus open and close on UIKit's Reduce Motion spring. Motion that

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The current design:reduced-motion DR2 says a Reduce Motion transition "MUST change opacity only and MUST finish in 200 ms or less", and this head's animator uses 200 ms (motion/uikit/timing.ts:151). A 0.5 s push or pop and a spring-timed sheet fade contradict it, and the Current-state impact does not name that record.

'@astryxdesign/core': patch
---

[fix] `useScrollLock`, and so Dialog, BottomSheet, Drawer and Lightbox, keeps the page where it is while an overlay is open. The lock used to pin `<body>` with `position: fixed` at minus the scroll offset and scroll back on release: the page's offset read 0 for the whole lock, and on iOS Safari the jump to the top brought the toolbar back, where it stayed after the overlay closed. Now the page gets `overflow: hidden` (on the root, or on `<body>` when the body's own overflow is the one the viewport uses), and on touch screens a one-finger pan that would scroll the page is cancelled, which iOS Safari before 26.4 needs because it scrolls an `overflow: hidden` page under a finger. Pans that a scroller inside the overlay can take, and pinch-zoom, still work. The lock no longer pins `<body>`, and `window.scrollY` keeps its value while the lock holds. Pans are judged through the shared gesture host, after a small slop so a swipe's first wobble does not fix the wrong axis. MobileNav's own clip goes through the same page scroll-style claims, so closing or unmounting it no longer releases another overlay's lock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head MobileNav has no clip of its own; it calls useScrollLock (see mobile-nav-shared-scroll-lock.md, released with this note). The note also leaves out that the lock writes overscroll-behavior-y: none on the root and <body>, which turns off pull-to-refresh while an overlay is open.

Comment thread .changeset/touch-presentation.md Outdated
'@astryxdesign/core': patch
---

[feat] `presentation="adaptive"` on DropdownMenu, ContextMenu, MoreMenu, Selector and MultiSelector, and a DropdownMenu's default sub-menu drill-in, now use the theme's compact definition with a coarse primary pointer: a viewport narrower than the theme's `md` point (768px by default) or shorter than 500px. This is a behavior change. The old default was a bottom sheet at 768px wide and below on a coarse pointer. Now a coarse-pointer screen exactly 768px wide keeps the anchored popover, and a phone in landscape (wider than 768px but shorter than 500px) gets the bottom sheet and the drilled-in sub-menu. To pin a component's presentation regardless of the rule, pass `presentation="popover"` or `presentation="bottom-sheet"`. Mouse and keyboard users on desktop see no change, because every case requires a coarse pointer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

COMPACT_TOUCH_QUERY is built from DEFAULT_COMPACT_DEFINITION (touchPresentation.ts:71), so a theme that overrides md does not move this edge. Either say 768px, or make the resolver read the theme's effective md as spec:AST-012 FR9 describes.

@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.

This pull request is on hold for an engineering owner's decision: alongside the MobileNav fix, its compare view ships stable additions that no current spec decides (defineTheme touch motion roles, the press-feedback selection lock for touch and pen, and the astryxStylex undefinedVariables option) and the internal gesture and motion system behind them. An engineering owner needs to approve or reject them by commenting /approve-spec edc6cee7bf1e515195ab93322157d722ca2d0fe0 on the pull request; as an engineering owner, the author may record that decision too. Whatever the decision, these need changes before approval:

  • Land or drop the #8098 stack first and rebase, so this pull request carries only its 96-line MobileNav commit instead of 34,611 authored lines.
  • Keep the gesture host out of useScrollLock's static imports, since CI's size report shows Dialog, BottomSheet, Drawer, Lightbox, MobileNav and 18 other components each growing by about 17 KB gzip for a touch-only page hold (architecture:component-style-authoring INV6).
  • Limit the press selection lock to touch or show pen evidence (spec:AST-017 FR59), and release it and the new build option under accurate changeset tags.
  • Correct scroll-lock-keeps-the-page.md, which still describes a MobileNav clip, and make reduced-motion-record.md an internal [chore] note.

[Automated review]

*/

import {useEffect} from 'react';
import {claimRootScrollStyle, reportScrollLock} from './rootScrollStyles';
import {getGestureHost} from '../gestures/GestureHost';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This static import puts the whole gesture host (arena, pan and tap recognizers, the UIKit gesture table) into every component that reaches useScrollLock: CI's size report shows Dialog going from 13,787 to 30,964 gzip bytes and 22 other components growing by about 17 KB each. The page hold only needs document and isTouchClaimed, which answers false while no recognizer is attached. architecture:component-style-authoring INV6 asks a touch-only feature over 2 KB to load lazily or justify the cost; reading an already-created host from a small registry module, treating no host as nothing claimed, would keep the hold and drop the weight.

@@ -283,6 +301,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

onPointerDown returns early only for mouse, so a pen takes this lock as a finger does. spec:AST-017 FR59 lets touch work change what fine-pointer users do only for a defect they hit or a named cross-device reason, with before-and-after evidence, and none is in this pull request. Limiting the lock to pointerType === 'touch', or naming the reason and showing pen evidence, would settle it; the [fix] label also needs a documented contract to restore.

'@astryxdesign/core': patch
---

[fix] `useScrollLock`, and so Dialog, BottomSheet, Drawer and Lightbox, keeps the page where it is while an overlay is open. The lock used to pin `<body>` with `position: fixed` at minus the scroll offset and scroll back on release: the page's offset read 0 for the whole lock, and on iOS Safari the jump to the top brought the toolbar back, where it stayed after the overlay closed. Now the page gets `overflow: hidden` (on the root, or on `<body>` when the body's own overflow is the one the viewport uses), and on touch screens a one-finger pan that would scroll the page is cancelled, which iOS Safari before 26.4 needs because it scrolls an `overflow: hidden` page under a finger. Pans that a scroller inside the overlay can take, and pinch-zoom, still work. The lock no longer pins `<body>`, and `window.scrollY` keeps its value while the lock holds. Pans are judged through the shared gesture host, after a small slop so a swipe's first wobble does not fix the wrong axis. MobileNav's own clip goes through the same page scroll-style claims, so closing or unmounting it no longer releases another overlay's lock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

At this head MobileNav has no clip of its own; it calls useScrollLock (see mobile-nav-shared-scroll-lock.md, released with this note), so the last sentence describes code that is gone. The note also leaves out that the lock writes overscroll-behavior-y: none on the root and <body>, which turns off Chrome on Android's pull-to-refresh while an overlay is open.

Comment thread .changeset/reduced-motion-record.md Outdated
'@astryxdesign/core': patch
---

[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This head exports no touch animator (no motion entry in packages/core/package.json), so a [fix] note about its options tells consumers about an API they cannot import. Its siblings use [chore] Internal: …; this one should too.

'@astryxdesign/build': patch
---

[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. A constant defined on the library side of the layer split and read on the product side stayed unresolved in either form. A production build now also warns, naming the variable and the rule that reads it, when its stylesheet reads a StyleX-generated custom property that nothing declares; set `undefinedVariables: 'error'` to fail the build instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The cross-file defineConsts fix fits [fix], but the new undefinedVariables option and its default production warning are a compatible capability, which spec:AST-017 FR7 releases as [feat]. A separate changeset for the option would keep each category accurate.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
The MobileNav touch presentation proof needs no workflow step: pr-a11y
discovers it by name and runs it on a change to MobileNav.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026

@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.

Changes requested. Switching MobileNav to the shared useScrollLock is small and matches the current MobileNav and touch-presentation requirements, but this pull request still carries the open #8098, #8320 and #8076 stack, about 29,000 authored lines beside MobileNav's 217. Please merge it after that stack lands and rebase so only the MobileNav files remain.

  • scripts/touch-support-bundle.test.mjs currently fails its root-entry and hooks-entry cases because useScrollLock now imports gestures/pageHold; the head that merges needs that test's replacement or a lock that keeps the page hold out of those entries.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
Merged on meta#8098 (5d9fc1a), the base both carry, so that only meta#8119's own
MobileNav files apply on top of meta#8342 and meta#8098.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
@vjeux
vjeux force-pushed the uikit/c14-mobilenav-scroll-lock branch from 284dbd5 to f40ce29 Compare November 3, 2026 07:17
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026

@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.

This pull request is on hold for an engineering owner's decision, and it also needs two changes whatever that decision is.

Approval needs an engineering owner to approve the three component-record edits this head makes, because no existing spec decides them:

  • component:Drawer FR5 and component:BottomSheetSwitcher FR5 now say pull-to-refresh and the page's bounce stop behind a modal (overscroll-behavior-y: none), which the cited spec:AST-094 FR19 does not cover, since it only lets a modal lock cancel one-finger pans.
  • component:MobileNav FR3 moves the drawer's clip from the page's root to <body> when <body> sets its own overflow.

An engineering owner records the decision by commenting /approve-spec f40ce2995f5f0c9811bf18bfc67e010e70e0a177 on this pull request; the author may do so if they are an engineering owner.

Required changes:

  • 2,156 of the 2,224 changed lines are #8098's useScrollLock rewrite, and the branch conflicts with main, so land #8098 first and rebase until only the MobileNav commit remains (merging both GESTURE_USERS lists in scripts/touch-support-bundle.test.mjs).
  • .changeset/scroll-lock-keeps-the-page.md still says "MobileNav's own clip goes through the same page scroll-style claims", but MobileNav has no clip of its own at this head (it calls useScrollLock), so drop that sentence.

The MobileNav switch itself is small, its unit suites pass, and on a full-height page it keeps the scroll offset that main loses.

[Automated review]

'@astryxdesign/core': patch
---

[fix] `useScrollLock`, and so Dialog, BottomSheet, Drawer and Lightbox, keeps the page where it is while an overlay is open. The lock used to pin `<body>` with `position: fixed` at minus the scroll offset and scroll back on release: the page's offset read 0 for the whole lock, and on iOS Safari the jump to the top brought the toolbar back, where it stayed after the overlay closed. Now the page gets `overflow: hidden` (on the root, or on `<body>` when the body's own overflow is the one the viewport uses), and on touch screens a one-finger pan that would scroll the page is cancelled, which iOS Safari before 26.4 needs because it scrolls an `overflow: hidden` page under a finger. Pans that a scroller inside the overlay can take, drags on a native range input, pinch-zoom, and one-finger drags on a pinch-zoomed page (they pan the zoomed view) still work. The lock no longer pins `<body>`, and `window.scrollY` keeps its value while the lock holds. The lock cancels those pans itself, leaving a touch a gesture recognizer owns to it, and judges each pan after a small slop so a swipe's first wobble does not fix the wrong axis. It follows the browser's scroll chain, so an overlay rendered inside a scrolling container still holds the page: a modal dialog's pans go to the page, not to that container. It also sets `overscroll-behavior-y: none` on the root and `<body>`, which turns off pull-to-refresh (Chrome on Android) while an overlay is open. MobileNav's own clip goes through the same page scroll-style claims, so closing or unmounting it no longer releases another overlay's lock.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The last sentence says "MobileNav's own clip goes through the same page scroll-style claims", but at this head MobileNav has no clip of its own: it calls useScrollLock(isOpen) (MobileNav.tsx:521), as mobile-nav-shared-scroll-lock.md says. Please drop that sentence here, so the release notes describe MobileNav once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped at 36c3088: the release notes now describe MobileNav once, in mobile-nav-shared-scroll-lock.md.

@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.

This pull request is on hold for an engineering owner's decision, and it also needs two changes whatever that decision is.

Approval needs an engineering owner to approve the three component-record edits this head makes, because no existing spec decides them:

  • component:Drawer FR5 and component:BottomSheetSwitcher FR5 now say pull-to-refresh and the page's bounce stop behind a modal (overscroll-behavior-y: none), which the cited spec:AST-094 FR19 does not cover, since it only lets a modal lock cancel one-finger pans.
  • component:MobileNav FR3 moves the drawer's clip from the page's root to <body> when <body> sets its own overflow.

An engineering owner records the decision by commenting /approve-spec 36c308845072b775444751b187a03c60256ff930 on this pull request; the author may do so if they are an engineering owner.

Required changes:

  • 2,156 of the 2,224 changed lines are #8098's useScrollLock rewrite, and the branch conflicts with main in Drawer.spec.md, scrollbarGutter.ts and scripts/touch-support-bundle.test.mjs, so land #8098 first and rebase until only the MobileNav commit remains, merging both GESTURE_USERS lists.
  • packages/core/src/hooks/scrollLockTouchGuard.ts:36-57 copies the scroll-chain walk and the canMoveX/canMoveY checks of createNativeScrollProbe in packages/core/src/gestures/nativeScroll.ts, and the two already differ at modal dialogs and overscroll-behavior boundaries, so extend that probe with what the lock needs and call it instead of keeping a second copy.

The MobileNav switch itself is small, its unit and Chromium browser suites pass at this head, and on a full-height page it keeps the scroll offset that main loses.

[Automated review]

Comment on lines +36 to +38
const EDGE_TOLERANCE = 1;

function canMoveY(element: Element, dy: number, range: number): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

EDGE_TOLERANCE, canMoveY and canMoveX here repeat packages/core/src/gestures/nativeScroll.ts (lines 77 and 189-212), and the walk below repeats createNativeScrollProbe's scroll chain. The two already differ (this one stops at a modal or fixed element and treats an overscroll-behavior boundary as taking the pan; the probe reads touch-action and walks past a modal), so a fix to one misses the other. Extending the probe with the chain end and a "reaches the page" answer, and calling it from isPanAbsorbedBeforePage, keeps one path; nativeScroll.ts imports nothing of the gesture host.

* @input The element whose width a scroll lock holds, plus live layout
* measurements
* @output Exports holdScrollbarGutter and the ScrollbarGutterHold it returns
* @position Internal hook utility; used by useScrollLock and MobileNav so that

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: MobileNav no longer imports this hold at this head (it calls useScrollLock), so this line can say "used by useScrollLock".

* (gestures/pageHold) with the scroll lock's pan decision, and the
* scrollbar gutter hold
* @output Exports useScrollLock hook for locking page scroll behind a modal
* @position Core hook; used by Dialog, BottomSheet, Drawer and Lightbox to

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: MobileNav now takes this lock too, so this list can read "used by Dialog, BottomSheet, Drawer, Lightbox and MobileNav".

@vjeux
vjeux force-pushed the uikit/c14-mobilenav-scroll-lock branch from 36c3088 to 40a4ff7 Compare November 3, 2026 17:30
@vjeux vjeux changed the title fix(MobileNav): lock the page through the shared scroll lock test(MobileNav): a native close of the drawer releases the page lock Nov 3, 2026

@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.

This pull request is on hold for an engineering owner's decision, and it also needs three changes whatever that decision is.

Required changes:

  • 2,358 of the 2,385 changed lines are #8098's useScrollLock commit, so please land #8098 first and merge main, leaving only the new MobileNav case in this diff.
  • packages/core/src/hooks/scrollLockTouchGuard.ts:25-77 copies the edge test that canElementScroll in packages/core/src/gestures/nativeScroll.ts already exports (and the copies already differ on overflow: overlay), so please call canElementScroll from takesPan and keep only the overscroll-behavior and chain-end logic in the guard.
  • The new case's scrollbarGutter assertion (MobileNavCloseEdgeCases.test.tsx:332) cannot fail, because jsdom reports a 0 px viewport and the gutter is never held, so please stub a classic scrollbar as MobileNavScrollbarGutter.test.tsx does and assert stable while open, or describe the case as covering the overflow only.

Approval also needs an engineering owner to approve the record changes this head carries from #8098, because no existing spec decides them:

  • spec:AST-094 FR19 gains a sentence saying a modal scroll lock stops the page's pull-to-refresh and overscroll bounce, and component:Drawer FR5, component:BottomSheetSwitcher FR5 and component:MobileNav FR3 now say the same.
  • component:MobileNav FR3 and PR1 move the drawer's clip from the page's root to <body> when <body> sets its own overflow.

An engineering owner records the decision by commenting /approve-spec 40a4ff7e76ddafaa7486524f317eeabd23b2fe35 on this pull request; the author may do so if they are an engineering owner.

The new case itself is a useful guard: it passes on main and on this head, and on a full-height page the carried lock keeps the scroll offset that main loses.

[Automated review]

});

expect(document.documentElement.style.overflow).toBe('');
expect(document.documentElement.style.scrollbarGutter).toBe('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This assertion cannot fail: jsdom reports documentElement.clientWidth as 0, so holdScrollbarGutter returns its no-op and scrollbarGutter is already '' while the drawer is open. Stubbing a classic scrollbar (as stubViewport(1024, 1009) with CSS.supports('scrollbar-gutter', 'stable') in MobileNavScrollbarGutter.test.tsx) and asserting stable before the close would make the gutter half of the case real.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at 0a17209: the case stubs a classic scrollbar, as MobileNavScrollbarGutter.test.tsx does, and asserts stable while open and '' after the native close. A control without the lock's gutter release fails here.

Comment on lines +25 to +49
const SCROLLABLE = new Set(['auto', 'scroll']);

/** Overflow at or below this many px is a box that fits, not a scroller. */
const EDGE_TOLERANCE = 1;

function canMoveY(element: Element, dy: number, range: number): boolean {
// A finger moving down drags the content down: scrollTop decreases.
return dy > 0
? element.scrollTop > EDGE_TOLERANCE - 0.5
: element.scrollTop < range - EDGE_TOLERANCE + 0.5;
}

function canMoveX(
element: Element,
dx: number,
range: number,
isRtl: boolean,
): boolean {
const min = isRtl ? -range : 0;
const max = isRtl ? 0 : range;
// A finger moving right drags the content right: scrollLeft decreases.
return dx > 0
? element.scrollLeft > min + EDGE_TOLERANCE - 0.5
: element.scrollLeft < max - EDGE_TOLERANCE + 0.5;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SCROLLABLE, EDGE_TOLERANCE, canMoveY and canMoveX repeat gestures/nativeScroll.ts (lines 77-78 and 192-217), whose exported canElementScroll already answers whether an element scrolls natively for a finger moving by (dx, dy); the copies already differ (overlay is missing here). Calling canElementScroll from takesPan and keeping only the overscroll-behavior containment and the chain end here keeps one edge test for the lock and the recognizers.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is #8098's code, now merged; this pull request no longer touches the file. The follow-up goes to that change's owner.

Comment on lines +27 to +30
// iOS Safari scrolls an `overflow: hidden` page under a finger.
// So the lock also cancels each one-finger pan that would reach the page. The
// scroll offset never changes, so unlock restores nothing, and iOS Safari's
// toolbar keeps its state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: this comment restates the hook's JSDoc (the kept offset and the one-finger cancel). Keeping only the reason in the JSDoc is enough: "iOS Safari scrolls an overflow: hidden page under a finger, so the lock cancels a one-finger pan that would reach the page."

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is #8098's code, now merged; this pull request no longer touches the file. The follow-up goes to that change's owner.

A `<form method="dialog">` submit, or a close() through a ref, closes
the drawer's dialog before the app's isOpen follows. The case pins that
the page lock MobileNav takes through useScrollLock still releases the
root's overflow and scrollbar gutter once isOpen turns false. It stubs a
classic scrollbar, so the lock holds the gutter while the drawer is open.
Test only.
@vjeux
vjeux force-pushed the uikit/c14-mobilenav-scroll-lock branch from 40a4ff7 to 0a17209 Compare November 3, 2026 18:22
@github-actions github-actions Bot removed needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval labels Nov 3, 2026

@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.

Recommended for approval. The new case covers MobileNav's native-close path, matches the current MobileNav contract (FR3), and fails when the page lock's release is broken. There are no blocking findings.

Non-blocking suggestion:

  • The current FR3 says closing releases three things (the overflow clip, the scrollbar gutter and the overscroll stop); asserting overscrollBehaviorY is '' on the root and <body> after the close would cover the third, and the description's FR3 quote could use the current wording.

[Automated review]

});

expect(document.documentElement.style.overflow).toBe('');
expect(document.documentElement.style.scrollbarGutter).toBe('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-blocking: the current component:MobileNav FR3 releases three things on close, and the lock writes overscroll-behavior-y: none on the root and <body> in the same claim as the overflow. These two lines after this one pass today and would pin the third release on the native-close path:

expect(document.documentElement.style.overscrollBehaviorY).toBe('');
expect(document.body.style.overscrollBehaviorY).toBe('');

@vjeux
vjeux marked this pull request as ready for review November 3, 2026 19:37
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 3, 2026
@cixzhang
cixzhang marked this pull request as draft November 3, 2026 20:32
@cixzhang
cixzhang marked this pull request as ready for review November 3, 2026 20:32
@cixzhang
cixzhang merged commit 7d76863 into meta:main Nov 3, 2026
190 of 201 checks passed
@vjeux
vjeux deleted the uikit/c14-mobilenav-scroll-lock branch November 3, 2026 22:46
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
The two bases merged with none of this PR's own change, so a compare from
here shows only that change. The gesture files are meta#8119's (the gesture
line with meta#8098's README note); meta#8342 leaves them as meta#8336 has them.
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Nov 3, 2026
The two bases merged with none of this PR's own change, so a compare from
here shows only that change.

This branch was successfully deployed

1 active deployment
Preview — 0a17209c Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) 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.

2 participants