import { App, Component, mount, onError, onMounted, onPatched, onWillPatch, onWillUnmount, useState, } from "../../src"; import { Portal } from "../../src/"; import { xml } from "../../src/tags"; import { makeTestFixture, nextTick, snapshotEverything } from "../helpers"; let fixture: HTMLElement; let originalconsoleWarn = console.warn; let mockConsoleWarn: any; function addOutsideDiv(fixture: HTMLElement): HTMLElement { let outside = document.createElement("div"); outside.setAttribute("id", "outside"); fixture.appendChild(outside); return outside; } snapshotEverything(); beforeEach(() => { fixture = makeTestFixture(); mockConsoleWarn = jest.fn(() => {}); console.warn = mockConsoleWarn; }); afterEach(() => { console.warn = originalconsoleWarn; }); describe("Portal", () => { test("basic use of portal", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe('

2

1
'); }); test("conditional use of Portal", async () => { class Parent extends Component { static components = { Portal }; static template = xml` 1

2

`; state = useState({ hasPortal: false }); } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

2

1'); parent.state.hasPortal = false; await nextTick(); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

2

1'); }); test("conditional use of Portal (with sub Component)", async () => { class Child extends Component { static template = xml`

`; } class Parent extends Component { static components = { Portal, Child }; static template = xml` 1 `; state = useState({ hasPortal: false, val: 1 }); } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

1

1'); parent.state.hasPortal = false; await nextTick(); expect(fixture.innerHTML).toBe('
1'); parent.state.val = 2; await nextTick(); expect(fixture.innerHTML).toBe('
1'); parent.state.hasPortal = true; await nextTick(); expect(fixture.innerHTML).toBe('

2

1'); }); test("with target in template (before portal)", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } const parent = await mount(Parent, fixture); expect((parent.el as any)!.innerHTML).toBe( '

2

1' ); }); test("with target in template (after portal)", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } const parent = await mount(Parent, fixture); expect((parent.el as any)!.innerHTML).toBe( '1

2

' ); }); test("portal with target not in dom", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } let error: Error; try { await mount(Parent, fixture); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe("invalid portal target"); expect(fixture.innerHTML).toBe(`
`); expect(mockConsoleWarn).toBeCalledTimes(1); }); test("portal with child and props", async () => { const steps: string[] = []; const outside = addOutsideDiv(fixture); class Child extends Component { static template = xml``; setup() { onMounted(() => { steps.push("mounted"); expect(outside.innerHTML).toBe("1"); }); onPatched(() => { steps.push("patched"); expect(outside.innerHTML).toBe("2"); }); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ val: 1 }); } const parent = await mount(Parent, fixture); expect(outside.innerHTML).toBe("1"); expect((parent.el as any).innerHTML).toBe(""); parent.state.val = 2; await nextTick(); expect(outside.innerHTML).toBe("2"); expect((parent.el as any).innerHTML).toBe(""); expect(steps).toEqual(["mounted", "patched"]); }); test("portal with only text as content", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
only text
'); }); test("portal with no content", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe(`
`); }); test("portal with many children", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
1

2

`; } addOutsideDiv(fixture); await mount(Parent, fixture); expect(fixture.innerHTML).toBe('
1

2

'); }); test("portal with dynamic body", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; state = useState({ val: "ab" }); } const outside = addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(outside.innerHTML).toBe(`ab`); parent.state.val = ""; await nextTick(); expect(outside.innerHTML).toBe(`
`); }); test("portal could have dynamically no content", async () => { class Parent extends Component { static components = { Portal }; static template = xml`
`; state = useState({ val: "ab" }); } const outside = addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(outside.innerHTML).toBe(`ab`); parent.state.val = ""; await nextTick(); expect(outside.innerHTML).toBe(``); }); test("lifecycle hooks of portal sub component are properly called", async () => { const steps: any[] = []; class Child extends Component { static template = xml``; setup() { onMounted(() => steps.push("child:mounted")); onWillPatch(() => steps.push("child:willPatch")); onPatched(() => steps.push("child:patched")); onWillUnmount(() => steps.push("child:willUnmount")); } } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ hasChild: false, val: 1 }); setup() { onMounted(() => steps.push("parent:mounted")); onWillPatch(() => steps.push("parent:willPatch")); onPatched(() => steps.push("parent:patched")); onWillUnmount(() => steps.push("parent:willUnmount")); } } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(steps).toEqual(["parent:mounted"]); parent.state.hasChild = true; await nextTick(); expect(steps).toEqual([ "parent:mounted", "parent:willPatch", "child:mounted", "parent:patched", ]); parent.state.val = 2; await nextTick(); expect(steps).toEqual([ "parent:mounted", "parent:willPatch", "child:mounted", "parent:patched", "parent:willPatch", "child:willPatch", "child:patched", "parent:patched", ]); parent.state.hasChild = false; await nextTick(); expect(steps).toEqual([ "parent:mounted", "parent:willPatch", "child:mounted", "parent:patched", "parent:willPatch", "child:willPatch", "child:patched", "parent:patched", "parent:willPatch", "child:willUnmount", "parent:patched", ]); }); test("portal destroys on crash", async () => { class Child extends Component { static template = xml``; state = {}; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = { error: false }; setup() { onError((e) => (error = e)); } } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); let error: Error; parent.state.error = true; parent.render(); await nextTick(); expect(error!).toBeDefined(); const regexp = /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; expect(error!.message).toMatch(regexp); }); test("portal's parent's env is not polluted", async () => { class Child extends Component { static template = xml` `; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; } const env = {}; const app = new App(Parent); app.configure({ env }); addOutsideDiv(fixture); const parent = await mount(Parent, fixture); expect(parent.env).toStrictEqual({}); }); test("Portal composed with t-slot", async () => { const steps: Array = []; let childInst: Component | null = null; class Child2 extends Component { static template = xml`
child2
`; setup() { childInst = this; } onCustom(ev: Event) { this.props.customHandler(ev); } } class Child extends Component { static components = { Portal, Child2 }; static template = xml` `; } class Parent extends Component { static components = { Child, Child2 }; static template = xml`
`; _handled(ev: Event) { steps.push(ev.type as string); } } addOutsideDiv(fixture); await mount(Parent, fixture); childInst!.el!.dispatchEvent(new CustomEvent("custom")); expect(steps).toEqual(["custom"]); }); }); describe("Portal: UI/UX", () => { test("focus is kept across re-renders", async () => { class Child extends Component { static template = xml` `; } class Parent extends Component { static components = { Portal, Child }; static template = xml`
`; state = useState({ val: "ab" }); } addOutsideDiv(fixture); const parent = await mount(Parent, fixture); const input = document.querySelector("#target-me"); expect(input!.nodeName).toBe("INPUT"); expect((input as HTMLInputElement).placeholder).toBe("ab"); (input as HTMLInputElement).focus(); expect(document.activeElement === input).toBeTruthy(); parent.state.val = "bc"; await nextTick(); const inputReRendered = document.querySelector("#target-me"); expect(inputReRendered!.nodeName).toBe("INPUT"); expect((inputReRendered as HTMLInputElement).placeholder).toBe("bc"); expect(document.activeElement === inputReRendered).toBeTruthy(); }); }); describe("Portal: Props validation", () => { test("target is mandatory", async () => { const consoleInfo = console.info; console.info = jest.fn(); class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } let error: Error; let app = new App(Parent); app.configure({ dev: true }); try { await app.mount(fixture); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe(`Missing props 'target' (component 'Portal')`); console.info = consoleInfo; expect(mockConsoleWarn).toBeCalledTimes(1); }); test("target is not list", async () => { const consoleInfo = console.info; console.info = jest.fn(); class Parent extends Component { static components = { Portal }; static template = xml`
2
`; } let error: Error; let app = new App(Parent); app.configure({ dev: true }); try { await app.mount(fixture); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe(`Invalid Prop 'target' in component 'Portal'`); console.info = consoleInfo; expect(mockConsoleWarn).toBeCalledTimes(1); }); });