diff --git a/src/runtime/app.ts b/src/runtime/app.ts index caca43f5..3fc6f5d7 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -84,7 +84,10 @@ export class App< this.props = config.props || ({} as P); } - mount(target: HTMLElement, options?: MountOptions): Promise & InstanceType> { + mount( + target: HTMLElement | ShadowRoot, + options?: MountOptions + ): Promise & InstanceType> { App.validateTarget(target); if (this.dev) { validateProps(this.Root, this.props, { __owl__: { app: this } }); @@ -99,7 +102,7 @@ export class App< return new ComponentNode(Component, props, this, null, null); } - mountNode(node: ComponentNode, target: HTMLElement, options?: MountOptions) { + mountNode(node: ComponentNode, target: HTMLElement | ShadowRoot, options?: MountOptions) { const promise: any = new Promise((resolve, reject) => { let isResolved = false; // manually set a onMounted callback. diff --git a/src/runtime/blockdom/events.ts b/src/runtime/blockdom/events.ts index f2b3b474..9a6c7338 100644 --- a/src/runtime/blockdom/events.ts +++ b/src/runtime/blockdom/events.ts @@ -1,3 +1,4 @@ +import { inOwnerDocument } from "../utils"; import { config } from "./config"; type EventHandlerSetter = (this: HTMLElement, data: any) => void; @@ -28,7 +29,7 @@ function createElementHandler(evName: string, capture: boolean = false): EventHa function listener(ev: Event) { const currentTarget = ev.currentTarget as HTMLElement; - if (!currentTarget || !currentTarget.ownerDocument.contains(currentTarget)) return; + if (!currentTarget || !inOwnerDocument(currentTarget)) return; const data = (currentTarget as any)[eventKey]; if (!data) return; config.mainEventHandler(data, ev, currentTarget); diff --git a/src/runtime/hooks.ts b/src/runtime/hooks.ts index ebc3b1d1..7fecd555 100644 --- a/src/runtime/hooks.ts +++ b/src/runtime/hooks.ts @@ -1,6 +1,7 @@ import type { Env } from "./app"; import { getCurrent } from "./component_node"; import { onMounted, onPatched, onWillUnmount } from "./lifecycle_hooks"; +import { inOwnerDocument } from "./utils"; // ----------------------------------------------------------------------------- // useRef @@ -16,7 +17,7 @@ export function useRef(name: string): { el: return { get el(): T | null { const el = refs[name]; - return el?.ownerDocument.contains(el) ? el : null; + return inOwnerDocument(el) ? el : null; }, }; } diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 2bc5c8c0..b1c6c92e 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -27,13 +27,28 @@ export function batched(callback: Callback): Callback { }; } -export function validateTarget(target: HTMLElement) { +/** + * Determine whether the given element is contained in its ownerDocument: + * either directly or with a shadow root in between. + */ +export function inOwnerDocument(el?: HTMLElement) { + if (!el) { + return false; + } + if (el.ownerDocument.contains(el)) { + return true; + } + const rootNode = el.getRootNode(); + return rootNode instanceof ShadowRoot && el.ownerDocument.contains(rootNode.host); +} + +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 (target instanceof HTMLElement) { - if (!document.body.contains(target)) { + if (target instanceof HTMLElement || target instanceof ShadowRoot) { + if (!document.body.contains(target instanceof HTMLElement ? target : target.host)) { throw new OwlError("Cannot mount a component on a detached dom node"); } return; diff --git a/tests/shadow_dom/shadow_dom.test.ts b/tests/shadow_dom/shadow_dom.test.ts new file mode 100644 index 00000000..06233935 --- /dev/null +++ b/tests/shadow_dom/shadow_dom.test.ts @@ -0,0 +1,67 @@ +import { App, Component, useRef, xml } from "../../src"; +import { status } from "../../src/runtime/status"; +import { makeTestFixture, snapshotEverything } from "../helpers"; + +let fixture: HTMLElement; + +snapshotEverything(); + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +describe("shadow_dom", () => { + test("can mount app", async () => { + class SomeComponent extends Component { + static template = xml`
`; + } + + const container = document.createElement("div"); + fixture.appendChild(container); + const shadow = container.attachShadow({ mode: "open" }); + 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 { + static template = xml``; + + add() { + a = 3; + } + } + const container = document.createElement("div"); + fixture.appendChild(container); + const shadow = container.attachShadow({ mode: "open" }); + await new App(SomeComponent).mount(shadow); + expect(a).toBe(1); + shadow.querySelector("button")!.click(); + expect(a).toBe(3); + }); + + test("useRef hook", async () => { + let comp: SomeComponent; + class SomeComponent extends Component { + static template = xml`
`; + div = useRef("refName"); + setup() { + comp = this; + } + } + const container = document.createElement("div"); + fixture.appendChild(container); + const shadow = container.attachShadow({ mode: "open" }); + const mountedProm = new App(SomeComponent).mount(shadow); + expect(comp!.div.el).toBe(null); + await mountedProm; + expect(comp!.div.el).toBe(shadow.querySelector(".my-div")); + }); +});