Skip to content

fix(interaction): resolve interact-outside targets across shadow boundaries - #1056

Open
Nandan108 wants to merge 1 commit into
solidjs-community:nextfrom
Nandan108:fix/interact-outside-shadow-dom
Open

fix(interaction): resolve interact-outside targets across shadow boundaries#1056
Nandan108 wants to merge 1 commit into
solidjs-community:nextfrom
Nandan108:fix/interact-outside-shadow-dom

Conversation

@Nandan108

Copy link
Copy Markdown

Problem

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, 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 — so el.contains(target) is false and the element reads its own content as "outside". It dismisses on pointerdown, the content unmounts, and pointerup never 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's solid2 branch 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 of event.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 because Node.prototype.contains only walks one node tree; even document.contains(elementInAShadowRoot) is false.

Applied to the three target resolutions in makeInteractOutside. Because isEventOutside now resolves the real target before calling shouldExcludeElement, an excluded trigger inside a shadow root works too — that's the specific mechanism behind the Kobalte reopen loop.

The isConnected guard 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:

Case Without fix
pointerdown on the watched element's own shadow content must not trigger fails
focus moving into the watched element's own shadow content must not trigger fails
shouldExcludeElement receives the retargeted element, not the shadow host fails
pointerdown on a sibling in the same shadow root must still trigger passes — regression guard

The fourth is deliberate: it passes with and without the fix, guarding against over-correcting into an element that never dismisses.

Verification

  • pnpm test for this package: 70/70 (66 pre-existing + 4 new); SSR suite 4/4
  • Baseline before the change was 66/66, so nothing regressed
  • oxlint --max-warnings 0: clean, both src and test
  • tsc --noEmit: clean
  • verify:manifests: passes (102 manifests)

Two notes in the interest of full disclosure:

  • I could not run pnpm build locally — it fails at loadConfigFile with Failed to import module "unrun" (an optional peer of tsdown that neither pnpm install nor --frozen-lockfile pulls in here). It fails identically on unmodified next, and Build and Test is green in CI at 74ac166, 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.
  • I deliberately did not run Prettier. prettier --check fails on these files on unmodified next (the format workflow only runs on push: main), so --write would 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 / ariaHideOutside in the same file use node.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

…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-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d9df5cf

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

This PR includes changesets to release 1 package
Name Type
@solid-primitives/interaction 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

@netlify

netlify Bot commented Sep 7, 2026

Copy link
Copy Markdown

Deploy Preview for solid-primitives-v2 ready!

Name Link
🔨 Latest commit d9df5cf
🔍 Latest deploy log https://app.netlify.com/projects/solid-primitives-v2/deploys/6a9eb6a31ec09500082783fd
😎 Deploy Preview https://deploy-preview-1056--solid-primitives-v2.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: a703e63a-6b74-4eb5-8099-5017b099b0aa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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