Skip to content

fix(focus-trap): don't crash when unpause finds no focusable element#3220

Open
s-kthr wants to merge 1 commit into
chakra-ui:mainfrom
s-kthr:fix/nested-focus-trap-unpause-throw
Open

fix(focus-trap): don't crash when unpause finds no focusable element#3220
s-kthr wants to merge 1 commit into
chakra-ui:mainfrom
s-kthr:fix/nested-focus-trap-unpause-throw

Conversation

@s-kthr

@s-kthr s-kthr commented Jul 20, 2026

Copy link
Copy Markdown

Description

When two FocusTrap instances share a trap stack (the default), deactivating the inner one (e.g. a popover or menu) automatically calls unpause() on whichever trap is now on top of the stack (e.g. a dialog) as pure internal housekeeping — see activeFocusTraps.deactivateTrap():

deactivateTrap(trapStack: FocusTrap[], trap: FocusTrap) {
  const trapIndex = trapStack.indexOf(trap)
  if (trapIndex !== -1) {
    trapStack.splice(trapIndex, 1)
  }

  if (trapStack.length > 0) {
    trapStack[trapStack.length - 1].unpause()
  }
}

unpause() calls updateTabbableNodes() and then addListeners(), which synchronously calls getInitialFocusNode() to refocus (when delayInitialFocus: false, this happens inline; with the default delayInitialFocus: true it happens a tick later inside the setTimeout, but the underlying issue is the same). If, at that exact instant, the outer trap's container has no connected focusable element — a transient state that can legitimately happen while a nested overlay is being torn down mid re-render — getInitialFocusNode() throws unconditionally:

Error: Your focus-trap needs to have at least one focusable element
    at FocusTrap.getInitialFocusNode
    at FocusTrap.addListeners
    at FocusTrap.unpause
    at Object.deactivateTrap
    at FocusTrap.deactivate

This happens even though the outer trap (e.g. the dialog) is still fully active and its content is still visible on screen — it's not a real "no focusable element" misconfiguration, just a momentary DOM state during the inner trap's teardown.

Current behavior (updates)

Nesting focus traps (e.g. a Popover/Menu inside a Dialog, all sharing the default trap stack) can throw an uncaught error whenever the inner overlay closes, if the outer trap's container has zero connected focusable elements at that exact moment. This surfaces in real apps as nested Dialog → Popover → Menu UIs (e.g. a template picker with an edit menu inside a popover inside a message-composer dialog) throwing this error every time the innermost overlay closes, even though the outer dialog stays open and fully functional.

Minimal repro (see the added test): activate an outer trap configured with fallbackFocus: () => someElement, activate an inner trap (pausing the outer one via the shared stack), then remove someElement from the DOM before deactivating the inner trap. Deactivating the inner trap throws.

New behavior

unpause()'s own automatic re-focus (as opposed to an explicit activate() call) no longer throws when it can't find a focusable element. Instead, it now returns false from getInitialFocusNode() and skips focusing — the same sentinel the code already uses for an explicit initialFocus: false. activate() is unaffected: calling .activate() on a trap with no focusable element (a genuine misconfiguration) still throws exactly as before.

Implementation: addListeners() and getInitialFocusNode() take an isUnpausing flag, set only by the call from unpause(), so only that internal housekeeping path is softened.

Is this a breaking change (Yes/No):

No. The only externally observable difference is that unpause()'s automatic re-focus no longer throws in a situation where it previously crashed; activate()'s validation behavior (the documented/tested "throws when there is no valid focus target" case) is unchanged.

Additional Information

  • Added a regression test (packages/utilities/focus-trap/tests/focus-trap.test.ts) that reproduces the exact reported stack trace shape (getInitialFocusNodeaddListenersunpausedeactivateTrapdeactivate) using two traps on the shared trap stack.
  • Confirmed the new test fails against the current main source (throws with the reported error message) before the fix, and passes after.
  • All existing packages/utilities/focus-trap tests (16) still pass, plus the new one (17 total). tsc --noEmit and eslint src are clean for the package.
  • Added a changeset for @zag-js/focus-trap (patch).

Nesting overlays (e.g. a popover inside a dialog) share a single trap
stack. Deactivating the inner trap calls deactivateTrap(), which
automatically calls unpause() on the trap now on top of the stack as
internal housekeeping. If that trap's container has no connected
focusable element at that instant (a transient DOM state, not a real
misconfiguration), getInitialFocusNode() threw unconditionally and
crashed the app even though the outer trap was still legitimately
active.

addListeners()/getInitialFocusNode() now take an isUnpausing flag so
this internal auto-refocus path returns false (skip focusing) instead
of throwing, mirroring the existing initialFocus: false sentinel.
Explicit activate() calls are unaffected and still throw on a genuine
"no focusable element" misconfiguration.
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 08b154c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 86 packages
Name Type
@zag-js/focus-trap Patch
@zag-js/dialog Patch
@zag-js/drawer Patch
@zag-js/popover Patch
@zag-js/tour Patch
@zag-js/anatomy-icons Patch
@zag-js/anatomy Patch
@zag-js/core Patch
@zag-js/docs Patch
@zag-js/preact Patch
@zag-js/react Patch
@zag-js/solid Patch
@zag-js/svelte Patch
@zag-js/vanilla Patch
@zag-js/vue Patch
@zag-js/accordion Patch
@zag-js/angle-slider Patch
@zag-js/async-list Patch
@zag-js/avatar Patch
@zag-js/carousel Patch
@zag-js/cascade-select Patch
@zag-js/checkbox Patch
@zag-js/clipboard Patch
@zag-js/collapsible Patch
@zag-js/color-picker Patch
@zag-js/combobox Patch
@zag-js/date-input Patch
@zag-js/date-picker Patch
@zag-js/editable Patch
@zag-js/file-upload Patch
@zag-js/floating-panel Patch
@zag-js/hover-card Patch
@zag-js/image-cropper Patch
@zag-js/listbox Patch
@zag-js/marquee Patch
@zag-js/menu Patch
@zag-js/navigation-menu Patch
@zag-js/number-input Patch
@zag-js/pagination Patch
@zag-js/password-input Patch
@zag-js/pin-input Patch
@zag-js/presence Patch
@zag-js/progress Patch
@zag-js/qr-code Patch
@zag-js/radio-group Patch
@zag-js/rating-group Patch
@zag-js/scroll-area Patch
@zag-js/select Patch
@zag-js/signature-pad Patch
@zag-js/slider Patch
@zag-js/splitter Patch
@zag-js/steps Patch
@zag-js/switch Patch
@zag-js/tabs Patch
@zag-js/tags-input Patch
@zag-js/timer Patch
@zag-js/toast Patch
@zag-js/toc Patch
@zag-js/toggle-group Patch
@zag-js/toggle Patch
@zag-js/tooltip Patch
@zag-js/tree-view Patch
@zag-js/store Patch
@zag-js/types Patch
@zag-js/aria-hidden Patch
@zag-js/auto-resize Patch
@zag-js/collection Patch
@zag-js/color-utils Patch
@zag-js/utils Patch
@zag-js/date-utils Patch
@zag-js/dismissable Patch
@zag-js/dom-query Patch
@zag-js/file-utils Patch
@zag-js/focus-visible Patch
@zag-js/highlight-word Patch
@zag-js/hotkeys Patch
@zag-js/i18n-utils Patch
@zag-js/interact-outside Patch
@zag-js/json-tree-utils Patch
@zag-js/live-region Patch
@zag-js/popper Patch
@zag-js/rect-utils Patch
@zag-js/remove-scroll Patch
@zag-js/scroll-snap Patch
@zag-js/stringify-state Patch
svelte-kit-starter Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
zag-nextjs Ready Ready Preview Jul 20, 2026 11:02pm
zag-solid Ready Ready Preview Jul 20, 2026 11:02pm
zag-svelte Ready Ready Preview Jul 20, 2026 11:02pm
zag-vue Ready Ready Preview Jul 20, 2026 11:02pm
zag-website Ready Ready Preview Jul 20, 2026 11:02pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant