From d9df5cf97c44fb96f5a416e6e271efdfc9757d8f Mon Sep 17 00:00:00 2001 From: Samuel de Rougemont Date: Mon, 7 Sep 2026 15:04:13 +0200 Subject: [PATCH] fix(interaction): resolve interact-outside targets across shadow boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .changeset/interaction-shadow-dom-target.md | 5 + packages/interaction/src/index.ts | 52 ++++++++- .../interaction/test/interact-outside.test.ts | 106 ++++++++++++++++++ 3 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 .changeset/interaction-shadow-dom-target.md diff --git a/.changeset/interaction-shadow-dom-target.md b/.changeset/interaction-shadow-dom-target.md new file mode 100644 index 000000000..e4a441111 --- /dev/null +++ b/.changeset/interaction-shadow-dom-target.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/interaction": patch +--- + +fix: resolve interact-outside targets across shadow boundaries diff --git a/packages/interaction/src/index.ts b/packages/interaction/src/index.ts index baa26fbdf..ddf29e92a 100644 --- a/packages/interaction/src/index.ts +++ b/packages/interaction/src/index.ts @@ -282,6 +282,42 @@ export function createHideOutside(options: CreateHideOutsideOptions): void { ); } +/** + * The element an event actually originated from. + * + * `event.target` is retargeted at every shadow boundary the event crosses, so a listener on the + * document reports the outermost shadow *host* rather than the element that was interacted with. + * `composedPath()[0]` is that element; for an event that crosses no boundary the two are identical, + * so this is a no-op outside shadow DOM. + */ +function getEventTarget(event: Event): Element | null { + const path = typeof event.composedPath === "function" ? event.composedPath() : undefined; + const target = path?.[0] ?? event.target; + + return target instanceof Element ? target : null; +} + +/** + * `Node.prototype.contains`, but able to see through shadow boundaries. + * + * `contains` only walks the node tree it is called on, so it answers `false` for a `child` inside a + * shadow root — including `document.contains(elementInAShadowRoot)`. When this walk reaches the top + * of a shadow tree it continues from that tree's host, answering a containment question about the + * *rendered* page rather than about one node tree. + */ +function containsComposed(parent: Node, child: Node | null): boolean { + let node: Node | null = child; + + while (node) { + if (parent === node || parent.contains(node)) return true; + + const root = node.getRootNode(); + node = root instanceof ShadowRoot ? root.host : null; + } + + return false; +} + /** Detail payload carried by every outside-interaction `CustomEvent`. */ export type EventDetails = { /** The original DOM event that triggered the outside interaction. */ @@ -399,16 +435,20 @@ export function makeInteractOutside( // a *new* instance that opened in that same window — misreporting them // as outside interactions on this now-orphaned instance. if (!el.isConnected) return false; - const target = e.target as Element | null; - if (!(target instanceof Element)) return false; - if (!ownerDoc.contains(target)) return false; - if (el.contains(target)) return false; + // Resolved through `composedPath()`, not `e.target`: these listeners are on the document, so an + // interaction inside a shadow tree reports that tree's host instead. `el` rendered inside a + // shadow root would see every one of its own clicks as the host — an ancestor, not a descendant + // — and read them as outside interactions, dismissing itself on pointerdown. + const target = getEventTarget(e); + if (!target) return false; + if (!containsComposed(ownerDoc, target)) return false; + if (containsComposed(el, target)) return false; return !(options.shouldExcludeElement?.(target) ?? false); }; const onPointerDown = (e: PointerEvent) => { const handler = () => { - const target = e.target as Element | null; + const target = getEventTarget(e); if (!target || !isEventOutside(e)) return; target.addEventListener( @@ -438,7 +478,7 @@ export function makeInteractOutside( }; const onFocusIn = (e: FocusEvent) => { - const target = e.target as Element | null; + const target = getEventTarget(e); if (!target || !isEventOutside(e)) return; target.addEventListener( diff --git a/packages/interaction/test/interact-outside.test.ts b/packages/interaction/test/interact-outside.test.ts index 7cf638f7f..00aec29e2 100644 --- a/packages/interaction/test/interact-outside.test.ts +++ b/packages/interaction/test/interact-outside.test.ts @@ -307,4 +307,110 @@ describe("createInteractOutside", () => { second.cleanup(); }); }); + + describe("Shadow DOM", () => { + // These listeners live on the document, so an event originating inside a shadow tree is + // retargeted to that tree's host before it arrives. A watched element rendered inside a shadow + // root would therefore see every one of its own interactions reported as the host — an + // *ancestor* of it, not a descendant — so `el.contains(target)` read false and the element + // treated its own content as outside. The visible symptom is a popover that dismisses when you + // click inside it, and a trigger that closes and immediately reopens. + function setupShadowTest(extraProps: Partial = {}) { + const onFocusOutside = vi.fn(); + const onPointerDownOutside = vi.fn(); + const onInteractOutside = vi.fn(); + + const host = document.createElement("div"); + document.body.appendChild(host); + const shadowRoot = host.attachShadow({ mode: "open" }); + + // A child of the watched element, not the element itself: only a descendant is deep enough + // for retargeting to change the answer. + const inside = createElement("div", "inside"); + const insideChild = createElement("div", "inside-child"); + inside.appendChild(insideChild); + shadowRoot.appendChild(inside); + + // A sibling in the same shadow root — genuinely outside, and must stay that way. + const outside = createElement("div", "outside"); + shadowRoot.appendChild(outside); + + const dispose = createRoot(d => { + createInteractOutside( + { onFocusOutside, onPointerDownOutside, onInteractOutside, ...extraProps }, + () => inside, + ); + return d; + }); + + flush(); + vi.runAllTimers(); + + return { + mocks: { onFocusOutside, onPointerDownOutside, onInteractOutside }, + inside, + insideChild, + outside, + cleanup: () => { + dispose(); + host.remove(); + }, + }; + } + + it("does not trigger on pointerdown inside the watched element's own shadow content", () => { + const { mocks, insideChild, cleanup } = setupShadowTest(); + + insideChild.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, composed: true, pointerType: "mouse" }), + ); + + expect(mocks.onPointerDownOutside).not.toHaveBeenCalled(); + expect(mocks.onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it("does not trigger when focus moves into the watched element's own shadow content", () => { + const { mocks, insideChild, cleanup } = setupShadowTest(); + + insideChild.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true })); + + expect(mocks.onFocusOutside).not.toHaveBeenCalled(); + expect(mocks.onInteractOutside).not.toHaveBeenCalled(); + cleanup(); + }); + + it("still triggers on pointerdown on a sibling in the same shadow root", () => { + const { mocks, outside, cleanup } = setupShadowTest(); + + outside.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, composed: true, pointerType: "mouse" }), + ); + + expect(mocks.onPointerDownOutside).toHaveBeenCalledTimes(1); + expect(mocks.onInteractOutside).toHaveBeenCalledTimes(1); + cleanup(); + }); + + it("passes the retargeted element, not the shadow host, to shouldExcludeElement", () => { + // The mechanism behind kobaltedev/kobalte#445: a consumer excludes its trigger via + // `shouldExcludeElement`, but was handed the shadow host, which never matches the trigger — + // so the layer dismissed on the very interaction that was meant to be exempt. + const seen: Element[] = []; + const { outside, cleanup } = setupShadowTest({ + shouldExcludeElement: el => { + seen.push(el); + return false; + }, + }); + + outside.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, composed: true, pointerType: "mouse" }), + ); + + expect(seen).toContain(outside); + expect(seen.some(el => el.shadowRoot != null)).toBe(false); + cleanup(); + }); + }); });