diff --git a/src/utils/brisa-element/index.test.ts b/src/utils/brisa-element/index.test.ts index 72e4df5a4..b301fe332 100644 --- a/src/utils/brisa-element/index.test.ts +++ b/src/utils/brisa-element/index.test.ts @@ -1847,5 +1847,309 @@ describe("utils", () => { "
TRUE
TRUE
0", ); }); + + it("should be possible to render undefined and null", () => { + const Component = ({}, { h }: any) => + h(null, {}, [ + ["div", { class: "empty" }, undefined], + ["div", { class: "empty" }, null], + ]); + + customElements.define("test-component", brisaElement(Component)); + + document.body.innerHTML = ""; + const testComponent = document.querySelector( + "test-component", + ) as HTMLElement; + + expect(testComponent?.shadowRoot?.innerHTML).toBe( + '
', + ); + }); + + it("should not be possible to inject HTML as string directly", () => { + const Component = ({}, { h }: any) => + h(null, {}, ''); + + customElements.define("test-component", brisaElement(Component)); + + document.body.innerHTML = ""; + const testComponent = document.querySelector( + "test-component", + ) as HTMLElement; + + expect(testComponent?.shadowRoot?.innerHTML).toBe( + '', + ); + + const script = document.querySelector("script"); + + expect(script).toBeNull(); + expect( + testComponent?.shadowRoot?.firstChild?.nodeType === Node.TEXT_NODE, + ).toBeTruthy(); + }); + + it("should handle keyboard events", () => { + const mockAlert = mock((s: string) => {}); + const Component = ({}, { h }: any) => + h("input", { + onKeydown: () => { + mockAlert("Enter to onKeydown"); + }, + }); + + customElements.define("keyboard-events", brisaElement(Component)); + + document.body.innerHTML = ""; + const keyboardEventEl = document.querySelector( + "keyboard-events", + ) as HTMLElement; + + expect(keyboardEventEl?.shadowRoot?.innerHTML).toBe(""); + + const input = keyboardEventEl?.shadowRoot?.querySelector( + "input", + ) as HTMLInputElement; + + input.dispatchEvent(new KeyboardEvent("keydown")); + + expect(keyboardEventEl?.shadowRoot?.innerHTML).toBe(""); + expect(mockAlert).toHaveBeenCalledTimes(1); + expect(mockAlert.mock.calls[0][0]).toBe("Enter to onKeydown"); + }); + + it("should handle asynchronous updates", async () => { + const fetchData = () => + Promise.resolve({ json: () => Promise.resolve({ name: "Barbara" }) }); + const Component = ({}, { state, h }: any) => { + const user = state({ name: "Aral" }); + + h(null, {}, [ + [ + "button", + { + onClick: async () => { + const response = await fetchData(); + user.value = await response.json(); + }, + }, + "fetch", + ], + ["div", {}, () => user.value.name], + ]); + }; + + customElements.define("async-updates", brisaElement(Component)); + + document.body.innerHTML = ""; + const asyncUpdatesComp = document.querySelector( + "async-updates", + ) as HTMLElement; + + expect(asyncUpdatesComp?.shadowRoot?.innerHTML).toBe( + "
Aral
", + ); + + const button = asyncUpdatesComp?.shadowRoot?.querySelector( + "button", + ) as HTMLButtonElement; + + button.click(); + + expect(asyncUpdatesComp?.shadowRoot?.innerHTML).toBe( + "
Aral
", + ); + + await Bun.sleep(0); + + expect(asyncUpdatesComp?.shadowRoot?.innerHTML).toBe( + "
Barbara
", + ); + }); + + it("should update all items from a list consuming the same state signal at the same time", () => { + const Component = ({}, { state, h }: any) => { + const list = state(["one", "two", "three"]); + + return h(null, {}, [ + [ + "button", + { + onClick: () => { + list.value = list.value.map((item: string) => + item.toUpperCase(), + ); + }, + }, + "uppercase", + ], + ["ul", {}, () => list.value.map((item: string) => ["li", {}, item])], + ]); + }; + + customElements.define("test-component", brisaElement(Component)); + + document.body.innerHTML = ""; + const testComponent = document.querySelector( + "test-component", + ) as HTMLElement; + + expect(testComponent?.shadowRoot?.innerHTML).toBe( + "
  • one
  • two
  • three
", + ); + + const button = testComponent?.shadowRoot?.querySelector( + "button", + ) as HTMLButtonElement; + + button.click(); + + expect(testComponent?.shadowRoot?.innerHTML).toBe( + "
  • ONE
  • TWO
  • THREE
", + ); + }); + + it("should be possible to update a rendered DOM element after mount via ref", async () => { + const Component = ({}, { onMount, state, h }: any) => { + const ref = state(null); + + onMount(() => { + // Is not a good practice but is just for testing + ref.value.innerHTML = "test"; + }); + + return h(null, {}, [["div", { ref }, "original"]]); + }; + + customElements.define("test-component", brisaElement(Component)); + document.body.innerHTML = ""; + + const testComponent = document.querySelector( + "test-component", + ) as HTMLElement; + + expect(testComponent?.shadowRoot?.innerHTML).toBe("
original
"); + + await Bun.sleep(0); + + expect(testComponent?.shadowRoot?.innerHTML).toBe("
test
"); + }); + + it("should be possible to execute different onMount callbacks", async () => { + const mockFirstCallback = mock((s: string) => {}); + const mockSecondCallback = mock((s: string) => {}); + const Component = ({}, { onMount, h }: any) => { + onMount(() => { + mockFirstCallback("first"); + }); + onMount(() => { + mockSecondCallback("second"); + }); + + return h(null, {}, null); + }; + + customElements.define("test-component", brisaElement(Component)); + document.body.innerHTML = ""; + + await Bun.sleep(0); + + expect(mockFirstCallback).toHaveBeenCalledTimes(1); + expect(mockFirstCallback.mock.calls[0][0]).toBe("first"); + expect(mockSecondCallback).toHaveBeenCalledTimes(1); + expect(mockSecondCallback.mock.calls[0][0]).toBe("second"); + }); + + it("should cleanup an event registered on onMount when the component is unmounted", async () => { + const mockCallback = mock((s: string) => {}); + const Component = ({}, { onMount, cleanup, h }: any) => { + onMount(() => { + const onClick = () => mockCallback("click"); + document.addEventListener("click", onClick); + + cleanup(() => { + document.removeEventListener("click", onClick); + }); + }); + + return h(null, {}, null); + }; + + customElements.define("test-component", brisaElement(Component)); + + document.body.innerHTML = ""; + + await Bun.sleep(0); + + expect(mockCallback).toHaveBeenCalledTimes(0); + + document.dispatchEvent(new Event("click")); + + expect(mockCallback).toHaveBeenCalledTimes(1); + + const testComponent = document.querySelector( + "test-component", + ) as HTMLElement; + + testComponent.remove(); + + document.dispatchEvent(new Event("click")); + + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + it("should cleanup on unmount if a cleanup callback is registered in the root of the component", () => { + const mockCallback = mock((s: string) => {}); + const Component = ({}, { cleanup, h }: any) => { + cleanup(() => { + mockCallback("cleanup"); + }); + + return h(null, {}, null); + }; + + customElements.define("test-component", brisaElement(Component)); + + document.body.innerHTML = ""; + + const testComponent = document.querySelector( + "test-component", + ) as HTMLElement; + + testComponent.remove(); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback.mock.calls[0][0]).toBe("cleanup"); + }); + + it("should cleanup on unmount if a cleanup callback is registered in a nested component", () => { + const mockCallback = mock((s: string) => {}); + const Component = ({}, { cleanup, h }: any) => { + cleanup(() => { + mockCallback("cleanup"); + }); + + return h(null, {}, null); + }; + + const ParentComponent = ({}, { h }: any) => { + return h(null, {}, [["test-component", {}, null]]); + }; + + customElements.define("test-component", brisaElement(Component)); + customElements.define("parent-component", brisaElement(ParentComponent)); + + document.body.innerHTML = ""; + + const parentComponent = document.querySelector( + "parent-component", + ) as HTMLElement; + + parentComponent.remove(); + + expect(mockCallback).toHaveBeenCalledTimes(1); + expect(mockCallback.mock.calls[0][0]).toBe("cleanup"); + }); }); }); diff --git a/src/utils/brisa-element/index.ts b/src/utils/brisa-element/index.ts index 4a718bd1c..10b83453c 100644 --- a/src/utils/brisa-element/index.ts +++ b/src/utils/brisa-element/index.ts @@ -6,6 +6,7 @@ type StateSignal = { value: unknown }; type Render = ( props: Record, ctx: ReturnType & { + onMount(cb: () => void): void; css(strings: string[], ...values: string[]): void; h(tagName: string, attributes: Attr, children: unknown): void; _on: symbol; @@ -46,7 +47,9 @@ const setAttribute = (el: HTMLElement, key: string, value: string) => { el.namespaceURI === SVG_NAMESPACE && (key.startsWith("xlink:") || key === "href"); - if (isWithNamespace) { + if (key === "ref") { + (value as unknown as StateSignal).value = el; + } else if (isWithNamespace) { if (off) el.removeAttributeNS(XLINK_NAMESPACE, key); else el.setAttributeNS(XLINK_NAMESPACE, key, on ? "" : serializedValue); } else { @@ -76,10 +79,11 @@ export default function brisaElement( return attributesLowercase; } - connectedCallback() { + async connectedCallback() { this.ctx = signals(); const { state, effect } = this.ctx; const shadowRoot = this.attachShadow({ mode: "open" }); + const fnToExecuteAfterMount: (() => void)[] = []; this.p = {}; @@ -177,13 +181,16 @@ export default function brisaElement( if (tagName) parent.appendChild(el); } - render( + await render( { children: "slot", ...this.p }, { ...this.ctx, h: hyperScript, _on, _off, + onMount(cb: () => void) { + fnToExecuteAfterMount.push(cb); + }, // Handle CSS css(strings: string[], ...values: string[]) { const style = createElement("style"); @@ -192,6 +199,7 @@ export default function brisaElement( }, }, ); + for (const fn of fnToExecuteAfterMount) fn(); } // Clean up signals on disconnection diff --git a/src/utils/signals/index.test.ts b/src/utils/signals/index.test.ts index cb5c602b3..4400cf341 100644 --- a/src/utils/signals/index.test.ts +++ b/src/utils/signals/index.test.ts @@ -75,14 +75,14 @@ describe("signals", () => { const mockEffect = mock<(count: number | undefined) => void>(() => {}); const { state, effect } = signals(); const count = state(undefined); - expect(count.value).toBe(undefined); + expect(count.value).not.toBeDefined(); effect(() => { mockEffect(count.value); }); expect(mockEffect).toHaveBeenCalledTimes(1); - expect(mockEffect.mock.calls[0][0]).toBe(undefined); + expect(mockEffect.mock.calls[0][0]).not.toBeDefined(); count.value = 1; diff --git a/src/utils/signals/index.ts b/src/utils/signals/index.ts index 0c4491944..cc16fb9b8 100644 --- a/src/utils/signals/index.ts +++ b/src/utils/signals/index.ts @@ -1,9 +1,10 @@ type Effect = () => void | Promise; type Cleanup = Effect; +type Current = Effect | 0 | undefined; export default function signals() { - let cleanups = new Map(); - let current: Effect | 0 = 0; + let cleanups = new Map(); + let current: Current; return { cleanAll() { @@ -37,9 +38,9 @@ export default function signals() { else current = 0; }, cleanup(fn: Cleanup) { - const cleans = current ? cleanups.get(current) ?? [] : []; + const cleans = cleanups.get(current) ?? []; cleans.push(fn); - if (current) cleanups.set(current, cleans); + cleanups.set(current, cleans); }, }; }