Skip to content

Commit b0615ad

Browse files
authored
Ensure sibling Dialog components are scrollable on mobile (#3796)
This PR fixes an issue where opening a sibling dialog doesn't allow you to scroll the second (top most) dialog on iOS anymore. The issue here is that on iOS we have to do a lot of annoying work to make the dialog behave like a dialog and prevent scrolling _outside_ of the dialog. To make this all work, we PUSH and POP some information to know what the top-most dialog is. The moment there is 1 open dialog, and as long as there is 1 dialog we scroll lock the page, the moment there are 0 dialogs again we unlock the page again. Each dialog also has a set of "allowed containers", these are just container DOM nodes where we allow scrolling. But the issue is that we were dealing with stale data. These are the events you see when we open dialogs: ``` [Log] Open dialog #1 [Log] PUSH [Log] SCROLL_PREVENT → We start preventing scroll because there is 1 dialog [Log] Open dialog #2 [Log] PUSH → We opened a second dialog, but the `SCROLL_PREVENT` → was already active and doesn't re-evaluate [Log] POP → POP from dialog #1 because not the top-most anymore ``` Any time we `PUSH` we also track new meta data with the allowed containers. But we were already preventing scroll, so we had stale meta data. To solve this, I can think of 2 solutions: 1. The moment we POP, we also SCROLL_ALLOW again, but realize that we still have 1 dialog open, so we SCROLL_PREVENT again. This just feels wrong to me though. 2. Any time we PUSH / POP, we re-evaluate the meta data, and when receive the meta data in the SCROLL_PREVENT event listeners, we call the function to get the latest meta data not the old stale data. ## Test plan In both scenario's I first open the dialog, then scroll. **before:** https://github.com/user-attachments/assets/e4747e14-c80e-4f64-8ea0-ec43b1d2283d **after:** https://github.com/user-attachments/assets/c5cf1d59-1186-4a9f-84c7-4a67339f2d01 Fixes: #3378
1 parent f96538e commit b0615ad

File tree

3 files changed

+29
-8
lines changed

3 files changed

+29
-8
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Improve focus management in shadow DOM roots ([#3794](https://github.com/tailwindlabs/headlessui/pull/3794))
1313
- Don't accidentally open the `Combobox` when touching the `ComboboxButton` while dragging on mobile ([#3795](https://github.com/tailwindlabs/headlessui/pull/3795))
14+
- Ensure sibling `Dialog` components are scrollable on mobile ([#3796](https://github.com/tailwindlabs/headlessui/pull/3796))
1415

1516
## [2.2.8] - 2025-09-12
1617

packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ export function handleIOSLocking(): ScrollLockStep<ContainerMetadata> {
1515
return {
1616
before({ doc, d, meta }) {
1717
function inAllowedContainer(el: Element) {
18-
return meta.containers
19-
.flatMap((resolve) => resolve())
20-
.some((container) => container.contains(el))
18+
for (let resolve of meta().containers) {
19+
for (let element of resolve()) {
20+
if (element.contains(el)) {
21+
return true
22+
}
23+
}
24+
}
25+
26+
return false
2127
}
2228

2329
d.microTask(() => {

packages/@headlessui-react/src/hooks/document-overflow/overflow-store.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface DocEntry {
99
count: number
1010
d: Disposables
1111
meta: Set<MetaFn>
12+
computedMeta: Record<string, any>
1213
}
1314

1415
function buildMeta(fns: Iterable<MetaFn>) {
@@ -24,7 +25,7 @@ export type MetaFn = (meta: Record<string, any>) => Record<string, any>
2425
export interface Context<MetaType extends Record<string, any> = any> {
2526
doc: Document
2627
d: Disposables
27-
meta: MetaType
28+
meta: () => MetaType
2829
}
2930

3031
export interface ScrollLockStep<MetaType extends Record<string, any> = any> {
@@ -39,10 +40,12 @@ export let overflows = createStore(() => new Map<Document, DocEntry>(), {
3940
count: 0,
4041
d: disposables(),
4142
meta: new Set(),
43+
computedMeta: {},
4244
}
4345

4446
entry.count++
4547
entry.meta.add(meta)
48+
entry.computedMeta = buildMeta(entry.meta)
4649
this.set(doc, entry)
4750

4851
return this
@@ -53,16 +56,27 @@ export let overflows = createStore(() => new Map<Document, DocEntry>(), {
5356
if (entry) {
5457
entry.count--
5558
entry.meta.delete(meta)
59+
entry.computedMeta = buildMeta(entry.meta)
5660
}
5761

5862
return this
5963
},
6064

61-
SCROLL_PREVENT({ doc, d, meta }: DocEntry) {
65+
SCROLL_PREVENT(entry: DocEntry) {
6266
let ctx = {
63-
doc,
64-
d,
65-
meta: buildMeta(meta),
67+
doc: entry.doc,
68+
d: entry.d,
69+
70+
// The moment we `PUSH`, we also `SCROLL_PREVENT`. But a later `PUSH` will
71+
// not re-trigger a `SCROLL_PREVENT` because we are already in a locked
72+
// state.
73+
//
74+
// This `meta()` function is called lazily such that a `PUSH` or `POP`
75+
// that happens later can update the meta information. Otherwise we would
76+
// use stale meta information.
77+
meta() {
78+
return entry.computedMeta
79+
},
6680
}
6781

6882
let steps: ScrollLockStep<any>[] = [

0 commit comments

Comments
 (0)