From 9d378b0e7b2fce0621991f3af0619e7b4bdc28fc Mon Sep 17 00:00:00 2001 From: "Achraf (abz)" Date: Tue, 25 Mar 2025 15:25:23 +0100 Subject: [PATCH] [FIX] utils: Correct validation of mount target in shadow DOM/iframe Previously, `validateTarget` only checked if the target element or its host (if it was a ShadowRoot) was directly contained in the document body. This failed in cases where the target element was nested inside a shadow DOM, which itself was attached to the document. This commit introduces a new helper `isAttachedToDocument` that traverses through parent nodes and shadow roots to ensure that the target is ultimately attached to the given document. Additionally, it now throws a clear error if `document.defaultView` is missing, indicating that the target document is detached or invalid. This ensures proper validation of mount targets, including complex scenarios with shadow roots and iframes. --- src/runtime/utils.ts | 34 +++++++- .../__snapshots__/shadow_dom.test.ts.snap | 52 +++++++++++ tests/shadow_dom/shadow_dom.test.ts | 87 +++++++++++++++++++ 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 3644bbc6..7445fa47 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -35,13 +35,43 @@ export function inOwnerDocument(el?: HTMLElement) { return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); } +/** + * Determine whether the given element is contained in a specific root documnet: + * either directly or with a shadow root in between or in an iframe. + */ +function isAttachedToDocument( + element: HTMLElement | ShadowRoot, + documentElement: Document +): boolean { + let current: Node = element; + const shadowRoot = documentElement.defaultView!.ShadowRoot; + while (current) { + if (current === documentElement) { + return true; + } + if (current.parentNode) { + current = current.parentNode; + } else if (current instanceof shadowRoot && current.host) { + current = current.host; + } else { + return false; + } + } + return false; +} + export function validateTarget(target: HTMLElement | ShadowRoot) { // Get the document and HTMLElement corresponding to the target to allow mounting in iframes const document = target && target.ownerDocument; if (document) { - const HTMLElement = document.defaultView!.HTMLElement; + if (!document.defaultView) { + throw new OwlError( + "Cannot mount a component: the target document is not attached to a window (defaultView is missing)" + ); + } + const HTMLElement = document.defaultView.HTMLElement; if (target instanceof HTMLElement || target instanceof ShadowRoot) { - if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) { + if (!isAttachedToDocument(target, document)) { throw new OwlError("Cannot mount a component on a detached dom node"); } return; diff --git a/tests/shadow_dom/__snapshots__/shadow_dom.test.ts.snap b/tests/shadow_dom/__snapshots__/shadow_dom.test.ts.snap index 60644d42..6a2bf9a1 100644 --- a/tests/shadow_dom/__snapshots__/shadow_dom.test.ts.snap +++ b/tests/shadow_dom/__snapshots__/shadow_dom.test.ts.snap @@ -27,6 +27,58 @@ exports[`shadow_dom can mount app 1`] = ` }" `; +exports[`shadow_dom can mount app in closed shadow dom 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`shadow_dom can mount app inside a separate HTML document 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`shadow_dom can mount app inside a shadow child element 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + +exports[`shadow_dom can mount app inside an element in a shadow root inside an iframe 1`] = ` +"function anonymous(app, bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + return block1(); + } +}" +`; + exports[`shadow_dom useRef hook 1`] = ` "function anonymous(app, bdom, helpers ) { diff --git a/tests/shadow_dom/shadow_dom.test.ts b/tests/shadow_dom/shadow_dom.test.ts index 06233935..69848113 100644 --- a/tests/shadow_dom/shadow_dom.test.ts +++ b/tests/shadow_dom/shadow_dom.test.ts @@ -29,6 +29,24 @@ describe("shadow_dom", () => { expect(status(comp)).toBe("destroyed"); }); + test("can mount app in closed shadow dom", async () => { + class SomeComponent extends Component { + static template = xml`
`; + } + + const container = document.createElement("div"); + fixture.appendChild(container); + const shadow = container.attachShadow({ mode: "closed" }); + const app = new App(SomeComponent); + const comp = await app.mount(shadow); + const div = shadow.querySelector(".my-div"); + expect(div).not.toBe(null); + expect(shadow.contains(div)).toBe(true); + app.destroy(); + expect(shadow.contains(div)).toBe(false); + expect(status(comp)).toBe("destroyed"); + }); + test("can bind event handler", async () => { let a = 1; class SomeComponent extends Component { @@ -64,4 +82,73 @@ describe("shadow_dom", () => { await mountedProm; expect(comp!.div.el).toBe(shadow.querySelector(".my-div")); }); + + test("can mount app inside a shadow child element", async () => { + class SomeComponent extends Component { + static template = xml`
`; + } + const shadow = fixture.attachShadow({ mode: "open" }); + const shadowDiv = document.createElement("div"); + shadow.append(shadowDiv); + const app = new App(SomeComponent); + const comp = await app.mount(shadowDiv); + const div = shadow.querySelector(".my-div"); + expect(div).not.toBe(null); + expect(shadow.contains(div)).toBe(true); + app.destroy(); + expect(shadow.contains(div)).toBe(false); + expect(status(comp)).toBe("destroyed"); + }); + + test("can mount app inside a separate HTML document", async () => { + class SomeComponent extends Component { + static template = xml`
`; + } + + const separateDoc = document.implementation.createHTMLDocument(); + const container = separateDoc.createElement("div"); + separateDoc.body.appendChild(container); + + const app = new App(SomeComponent); + let error: Error; + try { + await app.mount(container); + } catch (e) { + error = e as Error; + } + expect(error!).toBeDefined(); + expect(error!.message).toBe( + "Cannot mount a component: the target document is not attached to a window (defaultView is missing)" + ); + }); + + test("can mount app inside an element in a shadow root inside an iframe", async () => { + class SomeComponent extends Component { + static template = xml`
`; + } + + const iframe = document.createElement("iframe"); + fixture.appendChild(iframe); + + const iframeDoc = iframe.contentDocument!; + const container = iframeDoc.createElement("div"); + iframeDoc.body.appendChild(container); + + const shadow = container.attachShadow({ mode: "open" }); + + const shadowTarget = iframeDoc.createElement("div"); + shadow.appendChild(shadowTarget); + + const app = new App(SomeComponent); + const comp = await app.mount(shadowTarget); + + const div = shadow.querySelector(".my-div"); + expect(div).not.toBe(null); + expect(shadow.contains(div)).toBe(true); + expect(iframeDoc.body.contains(container)).toBe(true); + + app.destroy(); + expect(shadow.contains(div)).toBe(false); + expect(status(comp)).toBe("destroyed"); + }); });