From dbf981d44a4e6edae55f1245a7296b34b004fd9d Mon Sep 17 00:00:00 2001 From: plusgut Date: Tue, 1 Sep 2026 10:40:12 +0200 Subject: [PATCH 1/3] feat(inheritance): implement callbacks via inheritance --- src/index.ts | 23 +----- src/utils.ts | 155 +++++++++++++++++++++-------------------- test/async.test.tsx | 4 +- test/base.test.tsx | 16 ++--- test/context.test.tsx | 12 ++-- test/error.test.tsx | 6 +- test/events.test.tsx | 10 +-- test/fragment.test.tsx | 4 +- test/host.test.tsx | 12 ++-- test/svg.test.tsx | 4 +- 10 files changed, 114 insertions(+), 132 deletions(-) diff --git a/src/index.ts b/src/index.ts index 990a237..33b023a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,18 +2,10 @@ import { effect, Signal, signal } from "@preact/signals-core"; import { reconcile } from "./reconciler/index"; import { ShadowCache } from "./reconciler/utils"; import type { CustomEvents, ForbiddenHTMLProperties, ReadonlyKeys, ShadowElement } from "./types"; -import { - connectedCallback, - disconnectedCallback, - parentsCacheSymbol, - PlusnewErrorEvent, - active, - addEventListener, - removeEventListener, -} from "./utils"; +import { parentsCacheSymbol, PlusnewErrorEvent, active } from "./utils"; export type { ShadowElement } from "./types"; -export { active, connectedCallback, disconnectedCallback } from "./utils"; +export { active, WebComponent as Webcomponent } from "./utils"; export function mount(render: () => ShadowElement, parent: HTMLElement): () => void { const shadowResult: ShadowCache = new ShadowCache(false); @@ -60,17 +52,6 @@ export function createComponent S }, ): T; } { - if ("connectedCallback" in Component.prototype === false) { - Component.prototype.connectedCallback = connectedCallback; - } - - if ("disconnectedCallback" in Component.prototype === false) { - Component.prototype.disconnectedCallback = disconnectedCallback; - } - - Component.prototype.addEventListener = addEventListener; - Component.prototype.removeEventListener = removeEventListener; - customElements.define(name, Component as any); return name as any; diff --git a/src/utils.ts b/src/utils.ts index a180d55..7a08956 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -33,101 +33,102 @@ export function dispatchError(element: Element, error: unknown) { const disconnect = Symbol("disconnect"); const shadowCache = Symbol("shadowCache"); const eventListenerSymbol = Symbol("eventListner"); - export const parentsCacheSymbol = Symbol("parentsCache"); -export function connectedCallback( - this: HTMLElement & { render: () => ShadowElement }, - opt?: { shadowRootInit?: Partial }, -): ShadowRoot { - let shadowRoot: null | ShadowRoot = null; - if (this.shadowRoot === null) { - shadowRoot = this.attachShadow({ mode: "open", ...opt?.shadowRootInit }); - - (this as any)[parentsCacheSymbol] = new Map(); - (this as any)[shadowCache] = new ShadowCache(false); - } else { - shadowRoot = this.shadowRoot; - } +export abstract class WebComponent extends HTMLElement { + abstract render(): ShadowElement; - (this as any)[disconnect] = effect(() => { - batch(() => { - const previousActiveElement = active.parentElement; - let result: ShadowElement; - try { - active.parentElement = this; - result = this.render(); - active.parentElement = previousActiveElement; - } catch (error) { - active.parentElement = previousActiveElement; - untracked(() => dispatchError(this, error)); - - return; - } + connectedCallback(this: WebComponent, opt?: { shadowRootInit?: Partial }) { + let shadowRoot: null | ShadowRoot = null; + if (this.shadowRoot === null) { + shadowRoot = this.attachShadow({ mode: "open", ...opt?.shadowRootInit }); - reconcile({ - parentElement: this.shadowRoot as ShadowRoot, - previousSibling: null, - shadowCache: (this as any)[shadowCache], - shadowElement: result, + (this as any)[parentsCacheSymbol] = new Map(); + (this as any)[shadowCache] = new ShadowCache(false); + } else { + shadowRoot = this.shadowRoot; + } + + (this as any)[disconnect] = effect(() => { + batch(() => { + const previousActiveElement = active.parentElement; + let result: ShadowElement; + try { + active.parentElement = this; + result = this.render(); + active.parentElement = previousActiveElement; + } catch (error) { + active.parentElement = previousActiveElement; + untracked(() => dispatchError(this, error)); + + return; + } + + reconcile({ + parentElement: this.shadowRoot as ShadowRoot, + previousSibling: null, + shadowCache: (this as any)[shadowCache], + shadowElement: result, + }); }); }); - }); - - return shadowRoot; -} - -export function disconnectedCallback(this: HTMLElement & { render: () => ShadowElement }) { - (this as any)[disconnect](); - (this as any)[parentsCacheSymbol].clear(); - (this as any)[shadowCache].unmount(); -} -export function addEventListener( - this: HTMLElement, - eventName: string, - listener: (event: Event) => unknown, - options?: boolean | AddEventListenerOptions, -) { - if (eventListenerSymbol in this === false) { - (this as any)[eventListenerSymbol] = {}; + return shadowRoot; } - if (eventName in (this as any)[eventListenerSymbol] === false) { - (this as any)[eventListenerSymbol][eventName] = new WeakMap(); + + disconnectedCallback(this: WebComponent) { + if (disconnect in this) { + (this as any)[disconnect](); + } + if (parentsCacheSymbol in this) { + (this as any)[parentsCacheSymbol].clear(); + } + if (shadowCache in this) { + (this as any)[shadowCache].unmount(); + } } - const listenerOverwrite = (evt: Event) => { - if (typeof options === "object" && options !== null && options?.once === true) { - (this as any)[eventListenerSymbol]?.[eventName]?.delete(listener); + addEventListener( + eventName: string, + listener: (event: Event) => unknown, + options?: boolean | AddEventListenerOptions, + ) { + if (eventListenerSymbol in this === false) { + (this as any)[eventListenerSymbol] = {}; + } + if (eventName in (this as any)[eventListenerSymbol] === false) { + (this as any)[eventListenerSymbol][eventName] = new WeakMap(); } - const result = listener(evt); + const listenerOverwrite = (evt: Event) => { + if (typeof options === "object" && options !== null && options?.once === true) { + (this as any)[eventListenerSymbol]?.[eventName]?.delete(listener); + } - if (result instanceof Promise && active.eventPromises !== null) { - active.eventPromises.push(result); - } - }; + const result = listener(evt); - (this as any)[eventListenerSymbol][eventName].set(listener, listenerOverwrite); + if (result instanceof Promise && active.eventPromises !== null) { + active.eventPromises.push(result); + } + }; - HTMLElement.prototype.addEventListener.call(this, eventName, listenerOverwrite, options); -} + (this as any)[eventListenerSymbol][eventName].set(listener, listenerOverwrite); -export function removeEventListener( - this: HTMLElement, - eventName: string, - listener: (event: Event) => void, -) { - if ( - eventListenerSymbol in this === true && - eventName in (this as any)[eventListenerSymbol] === true - ) { - const listenerOverwrite = (this as any)[eventListenerSymbol][eventName].get(listener); + super.addEventListener(eventName, listenerOverwrite, options); + } - if (listenerOverwrite !== undefined) { - (this as any)[eventListenerSymbol][eventName].delete(listener); + removeEventListener(this: HTMLElement, eventName: string, listener: (event: Event) => void) { + if ( + eventListenerSymbol in this === true && + eventName in (this as any)[eventListenerSymbol] === true + ) { + const listenerOverwrite = (this as any)[eventListenerSymbol][eventName].get(listener); - HTMLElement.prototype.removeEventListener.call(this, eventName, listenerOverwrite); + if (listenerOverwrite !== undefined) { + (this as any)[eventListenerSymbol][eventName].delete(listener); + + super.removeEventListener(eventName, listenerOverwrite); + } } } } diff --git a/test/async.test.tsx b/test/async.test.tsx index 9ae6c07..9ec740a 100644 --- a/test/async.test.tsx +++ b/test/async.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount, dispatchEvent } from "@plusnew/webcomponent"; +import { createComponent, mount, dispatchEvent, Webcomponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -19,7 +19,7 @@ describe("webcomponent", () => { const Component = createComponent( "test-async-dispatch", - class Component extends HTMLElement { + class Component extends Webcomponent { onfoo: (evt: CustomEvent) => void; #loading = signal(false); diff --git a/test/base.test.tsx b/test/base.test.tsx index d03b249..b8e52a8 100644 --- a/test/base.test.tsx +++ b/test/base.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount, prop } from "@plusnew/webcomponent"; +import { createComponent, mount, prop, Webcomponent } from "@plusnew/webcomponent"; import type { Signal } from "@preact/signals-core"; import { signal } from "@preact/signals-core"; @@ -18,7 +18,7 @@ describe("webcomponent", () => { it("creates basic component and updating its props", () => { const Component = createComponent( "test-base", - class Component extends HTMLElement { + class Component extends Webcomponent { @prop() accessor foo: string; #baz = signal("baz"); @@ -56,7 +56,7 @@ describe("webcomponent", () => { it("crates array based on given number", () => { const Component = createComponent( "test-array", - class Component extends HTMLElement { + class Component extends Webcomponent { @prop() accessor amount: number; render() { @@ -94,7 +94,7 @@ describe("webcomponent", () => { it("crates element if needed", () => { const Component = createComponent( "test-placeholder", - class Component extends HTMLElement { + class Component extends Webcomponent { @prop() accessor show: boolean; render() { @@ -135,7 +135,7 @@ describe("webcomponent", () => { const Component = createComponent( "test-container", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { containerRenderCount++; return ; @@ -145,7 +145,7 @@ describe("webcomponent", () => { const NestedComponent = createComponent( "test-nest", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { nestedRenderCount++; return `${foo.value}`; @@ -180,7 +180,7 @@ describe("webcomponent", () => { const NestedComponent = createComponent( "test-counter-constructor", - class Component extends HTMLElement { + class Component extends Webcomponent { #counter: Signal; constructor() { super(); @@ -206,7 +206,7 @@ describe("webcomponent", () => { const Component = createComponent( "test-container-rerender", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { containerRenderCounter += 1; return ; diff --git a/test/context.test.tsx b/test/context.test.tsx index bdf3513..217c765 100644 --- a/test/context.test.tsx +++ b/test/context.test.tsx @@ -1,10 +1,10 @@ import { expect } from "@esm-bundle/chai"; -import { mount, createComponent, findParent } from "@plusnew/webcomponent"; +import { mount, createComponent, findParent, Webcomponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; const Provider = createComponent( "test-provider", - class Component extends HTMLElement { + class Component extends Webcomponent { readonly foo = signal("bar"); render() { @@ -15,7 +15,7 @@ const Provider = createComponent( const Consumer = createComponent( "test-consumer", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { try { return findParent(Provider).foo.value; @@ -71,7 +71,7 @@ describe("webcomponent", () => { it("finds context inline", () => { const Component = createComponent( "test-inline", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { return {findParent(Provider).foo.value}; } @@ -97,7 +97,7 @@ describe("webcomponent", () => { it("finds context in event", () => { const Component = createComponent( "test-event", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { return ( @@ -136,7 +136,7 @@ describe("webcomponent", () => { it("no context", () => { const Injection = createComponent( "test-injection", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { return ( diff --git a/test/error.test.tsx b/test/error.test.tsx index 53e5747..14c7825 100644 --- a/test/error.test.tsx +++ b/test/error.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount } from "@plusnew/webcomponent"; +import { createComponent, mount, Webcomponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; function error(): never { @@ -21,7 +21,7 @@ describe("webcomponent", () => { it("creates broken component and should display error", () => { const Component = createComponent( "test-broken", - class Component extends HTMLElement { + class Component extends Webcomponent { #hasError = signal(false); render() { return this.#hasError.value ? ( @@ -55,7 +55,7 @@ describe("webcomponent", () => { // const Component = webcomponent( // "test-later-broken", - // class Component extends HTMLElement { + // class Component extends Webcomponent { // render() { // if (foo.value === true) { // return "good"; diff --git a/test/events.test.tsx b/test/events.test.tsx index a134f78..a49ff3d 100644 --- a/test/events.test.tsx +++ b/test/events.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, dispatchEvent, mount, prop } from "@plusnew/webcomponent"; +import { createComponent, dispatchEvent, mount, prop, Webcomponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -18,7 +18,7 @@ describe("webcomponent", () => { let counter = 0; const Component = createComponent( "test-base", - class Component extends HTMLElement { + class Component extends Webcomponent { render() { return ( { const NestedComponent = createComponent( "test-nested", - class NestedComponent extends HTMLElement { + class NestedComponent extends Webcomponent { onfoo: (evt: CustomEvent) => void; render(this: NestedComponent) { return