fix(interaction): resolve interact-outside targets across shadow boundaries - #1056
fix(interaction): resolve interact-outside targets across shadow boundaries#1056Nandan108 wants to merge 1 commit into
Conversation
…daries `makeInteractOutside` registers its `pointerdown`/`focusin` listeners on the document. Events crossing a shadow boundary are retargeted, so by the time they reach a document listener `event.target` is the outermost shadow host rather than the element actually interacted with. That inverts every containment check. An element watched inside a shadow root sees each of its own interactions reported as the host — an ancestor, not a descendant — so `el.contains(target)` is false and the element reads its own content as outside. The symptom is a popover that dismisses when you click inside it, and a trigger that closes then immediately reopens. Resolve the target via `composedPath()[0]` and walk shadow boundaries when testing containment, so the checks answer questions about the rendered page rather than about one node tree. This also hands the real element to `shouldExcludeElement`, which is what makes an excluded trigger work inside a shadow root. For an event that crosses no shadow boundary `composedPath()[0]` and `event.target` are identical, so this is a no-op outside shadow DOM. The `isConnected` guard for orphaned instances is preserved unchanged. Fixes the root cause of kobaltedev/kobalte#445, fixed in Kobalte's own copy of this primitive in kobaltedev/kobalte#722. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: d9df5cf The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
✅ Deploy Preview for solid-primitives-v2 ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Problem
makeInteractOutsideregisters itspointerdown/focusinlisteners on the document. Events crossing a shadow boundary are retargeted, so by the time they reach a document listenerevent.targetis the outermost shadow host, not the element that was actually interacted with.That inverts every containment check in
isEventOutside. An element watched inside a shadow root sees each of its own interactions reported as the host — an ancestor of it, not a descendant — soel.contains(target)isfalseand the element reads its own content as "outside". It dismisses onpointerdown, the content unmounts, andpointerupnever lands on anything.The user-visible symptoms are a popover that closes when you click inside it, and a trigger that closes then immediately reopens (dismiss fires on pointerdown, the trigger's own click then reopens).
This is the same defect as kobaltedev/kobalte#445, which this file's header notes as its origin. I fixed Kobalte's own copy in kobaltedev/kobalte#722 (merged, released in
@kobalte/core@0.13.14), and @jer3m01 asked for the fix to be ported here — Kobalte'ssolid2branch consumes this package in place of its own primitive, so without this the bug survives into Kobalte 2.Fix
Two private helpers, used only by
makeInteractOutside:getEventTarget(event)—composedPath()[0]instead ofevent.target. For an event that crosses no shadow boundary the two are identical, so this is a no-op outside shadow DOM.containsComposed(parent, child)—contains, but continuing from the host when the walk reaches the top of a shadow tree. Needed becauseNode.prototype.containsonly walks one node tree; evendocument.contains(elementInAShadowRoot)isfalse.Applied to the three target resolutions in
makeInteractOutside. BecauseisEventOutsidenow resolves the real target before callingshouldExcludeElement, an excluded trigger inside a shadow root works too — that's the specific mechanism behind the Kobalte reopen loop.The
isConnectedguard for orphaned instances is preserved unchanged. No public API change; the helpers are module-private.Tests
Four cases added to
test/interact-outside.test.ts, in the existing style:shouldExcludeElementreceives the retargeted element, not the shadow hostThe fourth is deliberate: it passes with and without the fix, guarding against over-correcting into an element that never dismisses.
Verification
pnpm testfor this package: 70/70 (66 pre-existing + 4 new); SSR suite 4/4oxlint --max-warnings 0: clean, bothsrcandtesttsc --noEmit: cleanverify:manifests: passes (102 manifests)Two notes in the interest of full disclosure:
pnpm buildlocally — it fails atloadConfigFilewithFailed to import module "unrun"(an optional peer oftsdownthat neitherpnpm installnor--frozen-lockfilepulls in here). It fails identically on unmodifiednext, andBuild and Testis green in CI at74ac166, so this looks like a local resolution quirk rather than anything to do with this change — but I'd rather flag it than have it look verified.prettier --checkfails on these files on unmodifiednext(the format workflow only runs onpush: main), so--writewould have buried a small fix under unrelated reformatting. I checked that none of my added lines differ from Prettier's output under the repo config.Left alone
createHideOutside/ariaHideOutsidein the same file usenode.contains(...)in their tree walk and mutation observer, and are likely to have a related shadow-DOM weakness. I've left that out to keep this focused on the reported defect — happy to look at it separately if you'd like.🤖 Generated with Claude Code