fix(TrapFocus): trap focus when content is added to an empty container#638
Merged
Conversation
size-limit report 📦
|
b6b3212 to
39bfc71
Compare
f016c99 to
56c93fd
Compare
Trapping a container with nothing focusable inside used to bail out early, leaving a leaked MutationObserver that could never trap (it was never added to the chain, so its callback's isLast() guard always failed). Now an empty container defers instead: it observes for focusable content being added (e.g. async-loaded dialog content) and performs the actual trap once it appears, as long as no other trap was triggered in the meantime (tracked via a monotonic counter). release() also disconnects the deferred observer when the instance never trapped, so it can't trap after close. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01We9NqptZjfbvXRL4hE1xri
56c93fd to
2d602b4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
TrapFocus.trap()created aMutationObserver, started observing, and then early-returned when the container had no focusable elements (and noinitialFocusEl) — before settingthis.trapped/ adding itself to the chain:Two problems with this:
release()bails on!this.trapped, so it never disconnected the observer. Everytrap()against an empty container leaked aMutationObserver.this.#isLast(), which compares the chain tail's root tothis.#root. Since the empty path returns beforechain.add(this), the instance is never in the chain, so#isLast()can never be true and the callback always bails. So a container that was empty at trap time and later received focusable content (e.g. async-loaded dialog content) would never trap focus.Fix
Turn the empty case into a deferred trap instead of a no-op:
trap()was triggered after this one (tracked via a monotonic#trapCounter, matching "no more trap focuses triggered after").release()now disconnects the deferred observer even when the instance never trapped, so a container that's closed before content loads can't trap afterwards (and doesn't leak).Re-calling
this.trap()reuses the normal trapping path. The trigger is preserved naturally because the empty path never moved focus, sogetActiveElement()still returns the original opener.Verification
tsc --noEmit→ 0 errors.oxlint→ clean.TrapFocus.test.ts→ 22/22 passing, including 3 new cases:How to test
release()it, then add content. It stays inactive (and no observer leaks).Found during a full package bug review.
Generated by Claude Code