Skip to content

Vue: stale scroll-lock meta breaks touch-scrolling in sibling Dialogs on iOS (React fixed in #3796, Vue never got the port) #3890

Description

@MILLERMARRU

What package is this bug report for?

@headlessui/vue

What version are you using?

Latest main (checked packages/@headlessui-vue/src/hooks/document-overflow/overflow-store.ts and handle-ios-locking.ts directly)

Reproduction URL

N/A — source-level finding, see mechanism below. I can put together a repro if useful.

Describe your issue

@headlessui-react fixed this exact bug in #3796 (merged 2025-09-17, "Ensure sibling Dialog components are scrollable on mobile"), but the Vue package never received the port.

Note: this is a different, more specific bug than the one tracked in #3584. #3584 is about the top-layer/stack architecture from #3242 (which sibling Dialog is "active" for Escape/outside-click) — that's a separate mechanism in a separate file. This issue is only about the iOS scroll-lock meta staleness in overflow-store.ts/handle-ios-locking.ts, fixed by #3796, unrelated to #3242's stack.

The bug

overflow-store.ts's SCROLL_PREVENT handler computes the allowed-containers meta once, at the moment the document transitions from unlocked to locked:

SCROLL_PREVENT({ doc, d, meta }: DocEntry) {
  let ctx = {
    doc,
    d,
    meta: buildMeta(meta),
  }
  ...
}

SCROLL_PREVENT only fires when willChange is true (the lock count goes from 0 to something >0). If a second Dialog/Popover/etc. opens while the document is already locked, only PUSH runs (updates entry.meta), SCROLL_PREVENT does not run again — so handle-ios-locking.ts's inAllowedContainer() closure keeps referencing the stale meta captured from only the first overlay's containers:

// handle-ios-locking.ts, current Vue source
function inAllowedContainer(el: HTMLElement) {
  return meta.containers
    .flatMap((resolve) => resolve())
    .some((container) => container.contains(el))
}

Concretely: open a Dialog, then open a second Dialog on top of it (both stay mounted — e.g. a confirm dialog over a form dialog). On iOS Safari, touches inside the second dialog aren't recognized as "inside an allowed container" (since its containers were never added to the frozen meta), so they get touchAction: none/preventDefault'd, making the second dialog unscrollable.

The fix that already landed for React (#3796)

Makes meta a lazily-evaluated function instead of a frozen snapshot, recomputed on every PUSH/POP:

// overflow-store.ts
interface DocEntry {
  ...
  computedMeta: Record<string, any>
}
PUSH(doc, meta) {
  ...
  entry.computedMeta = buildMeta(entry.meta)
  ...
},
POP(doc, meta) {
  ...
  entry.computedMeta = buildMeta(entry.meta)
  ...
},
SCROLL_PREVENT(entry: DocEntry) {
  let ctx = {
    doc: entry.doc,
    d: entry.d,
    meta() {
      return entry.computedMeta
    },
  }
  ...
}
// handle-ios-locking.ts
function inAllowedContainer(el: Element) {
  for (let resolve of meta().containers) {
    for (let element of resolve()) {
      if (element.contains(el)) return true
    }
  }
  return false
}

I confirmed the Vue package's overflow-store.ts/handle-ios-locking.ts still have the pre-#3796 shape (meta: buildMeta(meta) computed once in SCROLL_PREVENT, meta used as a plain object rather than a function in inAllowedContainer) — the Vue changelog has no entry corresponding to #3796.

Expected behavior

Same fix as #3796, ported to @headlessui-vue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions