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(
+ "fetch Aral
",
+ );
+
+ const button = asyncUpdatesComp?.shadowRoot?.querySelector(
+ "button",
+ ) as HTMLButtonElement;
+
+ button.click();
+
+ expect(asyncUpdatesComp?.shadowRoot?.innerHTML).toBe(
+ "fetch Aral
",
+ );
+
+ await Bun.sleep(0);
+
+ expect(asyncUpdatesComp?.shadowRoot?.innerHTML).toBe(
+ "fetch 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(
+ "uppercase ",
+ );
+
+ const button = testComponent?.shadowRoot?.querySelector(
+ "button",
+ ) as HTMLButtonElement;
+
+ button.click();
+
+ expect(testComponent?.shadowRoot?.innerHTML).toBe(
+ "uppercase ",
+ );
+ });
+
+ 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);
},
};
}