From 31eb0b148eaea7087d171aaa7dfdff23b23c7117 Mon Sep 17 00:00:00 2001 From: plusgut Date: Tue, 1 Sep 2026 16:27:40 +0200 Subject: [PATCH 1/7] chore(webcomponent): changed to mixin-api --- src/index.ts | 53 ++--------- src/types.ts | 2 +- src/utils.ts | 195 +++++++++++++++++++++++++---------------- test/async.test.tsx | 6 +- test/base.test.tsx | 34 +++---- test/context.test.tsx | 10 +-- test/error.test.tsx | 4 +- test/events.test.tsx | 14 +-- test/fragment.test.tsx | 9 +- test/host.test.tsx | 10 +-- test/svg.test.tsx | 4 +- 11 files changed, 168 insertions(+), 173 deletions(-) diff --git a/src/index.ts b/src/index.ts index 29d1e21..1c17397 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ 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 { parentsCacheSymbol, PlusnewErrorEvent, active } from "./utils"; +import type { CustomEvents, ShadowElement } from "./types"; +import { parentsCacheSymbol, active } from "./utils"; export type { ShadowElement } from "./types"; -export { active, WebComponent } from "./utils"; +export { active, WebComponent, type BasePropsType } from "./utils"; export function mount(render: () => ShadowElement, parent: HTMLElement): () => void { const shadowResult: ShadowCache = new ShadowCache(false); @@ -30,28 +30,10 @@ export function mount(render: () => ShadowElement, parent: HTMLElement): () => v }; } -export function createComponent( +export function createComponent HTMLElement>( name: string, - Component: { new (): T }, -): { - new ( - properties: Omit< - { [K in keyof T]?: T[K] } & { - [ - K in keyof T as undefined extends T[K] ? never : K extends keyof HTMLElement ? never : K - ]-?: T[K]; - }, - | ReadonlyKeys - | ForbiddenHTMLProperties - | "render" - | "connectedCallback" - | "disconnectedCallback" - > & { - children?: ShadowElement; - onplusnewerror?: (evt: PlusnewErrorEvent) => void; - }, - ): T; -} { + Component: T, +): T { customElements.define(name, Component as any); return name as any; @@ -141,25 +123,6 @@ export function dispatchEvent( - decoratorTarget: ClassAccessorDecoratorTarget, - accessor: ClassAccessorDecoratorContext, - ): ClassAccessorDecoratorResult => { - return { - set: function (value) { - if (accessor.access.has(this)) { - (decoratorTarget.get.call(this) as Signal).value = value; - } else { - decoratorTarget.set.call(this, signal(value) as U); - } - }, - get: function () { - return (decoratorTarget.get.call(this) as Signal).value; - }, - init(value) { - return signal(value) as U; - }, - }; - }; +export function prop(): () => Signal { + return () => signal() as Signal; } diff --git a/src/types.ts b/src/types.ts index 021f9be..f90be7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,7 @@ export function Fragment(props: { children: ShadowElement }) { type IsEqual = (() => T extends CheckA ? 1 : 2) extends () => T extends CheckB ? 1 : 2 ? Then : Else; -export type ReadonlyKeys = { +type ReadonlyKeys = { [P in keyof T]-?: IsEqual<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, never, P>; }[keyof T]; diff --git a/src/utils.ts b/src/utils.ts index 7a08956..5dcd05f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ -import { batch, effect, untracked } from "@preact/signals-core"; +import { batch, effect, Signal, untracked } from "@preact/signals-core"; import { ShadowCache } from "./reconciler/utils"; -import type { ShadowElement } from "./types"; +import type { ForbiddenHTMLProperties, ShadowElement } from "./types"; import { reconcile } from "./reconciler"; const ERROR = "plusnewerror"; @@ -35,100 +35,145 @@ const shadowCache = Symbol("shadowCache"); const eventListenerSymbol = Symbol("eventListner"); export const parentsCacheSymbol = Symbol("parentsCache"); -export abstract class WebComponent extends HTMLElement { - abstract render(): ShadowElement; +export type BasePropsType = Omit, ForbiddenHTMLProperties | "children"> & { + children?: ShadowElement; +}; + +type PropType Signal }> = { + [Prop in keyof T]: ReturnType["value"]; +}; - connectedCallback(this: WebComponent, opt?: { shadowRootInit?: Partial }) { - let shadowRoot: null | ShadowRoot = null; - if (this.shadowRoot === null) { - shadowRoot = this.attachShadow({ mode: "open", ...opt?.shadowRootInit }); +interface IComponent extends HTMLElement { + connectedCallback(opt?: { shadowRootInit: Partial }): ShadowRoot; + disconnectedCallback(): void; +} - (this as any)[parentsCacheSymbol] = new Map(); - (this as any)[shadowCache] = new ShadowCache(false); - } else { - shadowRoot = this.shadowRoot; +export function WebComponent Signal } = {}>( + props?: T, +): abstract new (props: PropType & BasePropsType) => PropType & IComponent { + abstract class Component extends HTMLElement implements IComponent { + constructor(_props: PropType & BasePropsType) { + super(); + if (props !== undefined) { + Object.defineProperties( + this, + Object.fromEntries( + Object.entries(props).map(([key, init]) => { + const signal = init(); + + return [ + key, + { + get: () => { + return signal.value; + }, + set: (value) => { + signal.value = value; + }, + }, + ]; + }), + ), + ); + } } - (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; - } + abstract render(): ShadowElement; - reconcile({ - parentElement: this.shadowRoot as ShadowRoot, - previousSibling: null, - shadowCache: (this as any)[shadowCache], - shadowElement: result, + connectedCallback(opt?: { shadowRootInit?: Partial }) { + 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; + } + + (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; - } - disconnectedCallback(this: WebComponent) { - if (disconnect in this) { - (this as any)[disconnect](); + return shadowRoot; } - if (parentsCacheSymbol in this) { - (this as any)[parentsCacheSymbol].clear(); - } - if (shadowCache in this) { - (this as any)[shadowCache].unmount(); - } - } - 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(); + disconnectedCallback() { + 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); + } + }; - super.addEventListener(eventName, listenerOverwrite, options); - } + (this as any)[eventListenerSymbol][eventName].set(listener, listenerOverwrite); - 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); - super.removeEventListener(eventName, listenerOverwrite); + if (listenerOverwrite !== undefined) { + (this as any)[eventListenerSymbol][eventName].delete(listener); + + super.removeEventListener(eventName, listenerOverwrite); + } } } } + + return Component as any; } diff --git a/test/async.test.tsx b/test/async.test.tsx index 5cd908a..a72408a 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, WebComponent } from "@plusnew/webcomponent"; +import { createComponent, mount, dispatchEvent, WebComponent, prop } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -19,9 +19,7 @@ describe("webcomponent", () => { const Component = createComponent( "test-async-dispatch", - class Component extends WebComponent { - onfoo: (evt: CustomEvent) => void; - + class Component extends WebComponent({ onfoo: prop<(evt: CustomEvent) => void>() }) { #loading = signal(false); render(this: Component) { return ( diff --git a/test/base.test.tsx b/test/base.test.tsx index 0b0ef4b..bc7f2a4 100644 --- a/test/base.test.tsx +++ b/test/base.test.tsx @@ -18,9 +18,7 @@ describe("webcomponent", () => { it("creates basic component and updating its props", () => { const Component = createComponent( "test-base", - class Component extends WebComponent { - @prop() accessor foo: string; - + class Component extends WebComponent({ foo: prop() }) { #baz = signal("baz"); render() { @@ -42,7 +40,7 @@ describe("webcomponent", () => { expect(container.childNodes.length).to.equal(1); - const component = container.childNodes[0] as HTMLElement; + const component = container.childNodes[0] as InstanceType; expect(component.tagName).to.equal("TEST-BASE"); expect(component.className).to.equal("some-class"); expect(component.childNodes.length).to.equal(0); @@ -56,9 +54,9 @@ describe("webcomponent", () => { it("crates array based on given number", () => { const Component = createComponent( "test-array", - class Component extends WebComponent { - @prop() accessor amount: number; - + class Component extends WebComponent({ + amount: prop(), + }) { render() { return [...Array(this.amount).keys()].map((value) =>
{value.toString()}
); } @@ -94,9 +92,7 @@ describe("webcomponent", () => { it("crates element if needed", () => { const Component = createComponent( "test-placeholder", - class Component extends WebComponent { - @prop() accessor show: boolean; - + class Component extends WebComponent({ show: prop() }) { render() { return this.show === true &&
; } @@ -135,7 +131,7 @@ describe("webcomponent", () => { const Component = createComponent( "test-container", - class Component extends WebComponent { + class Component extends WebComponent() { render() { containerRenderCount++; return ; @@ -145,7 +141,7 @@ describe("webcomponent", () => { const NestedComponent = createComponent( "test-nest", - class Component extends WebComponent { + class Component extends WebComponent() { render() { nestedRenderCount++; return `${foo.value}`; @@ -180,15 +176,9 @@ describe("webcomponent", () => { const NestedComponent = createComponent( "test-counter-constructor", - class Component extends WebComponent { - #counter: Signal; - constructor() { - super(); - - this.#counter = signal(0); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.#counter.value; - } + class Component extends WebComponent() { + #counter = signal(0); + render() { nestedRenderCounter += 1; return ( @@ -206,7 +196,7 @@ describe("webcomponent", () => { const Component = createComponent( "test-container-rerender", - class Component extends WebComponent { + class Component extends WebComponent() { render() { containerRenderCounter += 1; return ; diff --git a/test/context.test.tsx b/test/context.test.tsx index a369646..06a3997 100644 --- a/test/context.test.tsx +++ b/test/context.test.tsx @@ -4,7 +4,7 @@ import { signal } from "@preact/signals-core"; const Provider = createComponent( "test-provider", - class Component extends WebComponent { + class Component extends WebComponent(){ readonly foo = signal("bar"); render() { @@ -15,7 +15,7 @@ const Provider = createComponent( const Consumer = createComponent( "test-consumer", - class Component extends WebComponent { + 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 WebComponent { + 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 WebComponent { + class Component extends WebComponent(){ render() { return ( @@ -136,7 +136,7 @@ describe("webcomponent", () => { it("no context", () => { const Injection = createComponent( "test-injection", - class Component extends WebComponent { + class Component extends WebComponent(){ render() { return ( diff --git a/test/error.test.tsx b/test/error.test.tsx index 998e34d..2c80689 100644 --- a/test/error.test.tsx +++ b/test/error.test.tsx @@ -21,7 +21,7 @@ describe("webcomponent", () => { it("creates broken component and should display error", () => { const Component = createComponent( "test-broken", - class Component extends WebComponent { + 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 WebComponent { + // class Component extends WebComponent(){ // render() { // if (foo.value === true) { // return "good"; diff --git a/test/events.test.tsx b/test/events.test.tsx index 665ebbd..8569910 100644 --- a/test/events.test.tsx +++ b/test/events.test.tsx @@ -18,7 +18,7 @@ describe("webcomponent", () => { let counter = 0; const Component = createComponent( "test-base", - class Component extends WebComponent { + class Component extends WebComponent() { render() { return ( { const NestedComponent = createComponent( "test-nested", - class NestedComponent extends WebComponent { - onfoo: (evt: CustomEvent) => void; + class NestedComponent extends WebComponent({ onfoo: prop<(evt: CustomEvent) => void>() }) { render(this: NestedComponent) { return - ); - } - }, - ); + @define("test-click") + class Component extends WebComponent() { + #baz = signal(0); + + render() { + return ( + + ); + } + } mount(() => , container); diff --git a/tsconfig.json b/tsconfig.json index 1b73525..c8948d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "ES2024" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "target": "ESNEXT" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ "jsx": "react-jsx" /* Specify what JSX code is generated. */, // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ From 734d7567170acfe1fbbb14ecaace74452467ebe6 Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 2 Sep 2026 09:27:49 +0200 Subject: [PATCH 3/7] feat(register): add decorator register --- test/async.test.tsx | 47 +++++++------- test/base.test.tsx | 139 ++++++++++++++++++----------------------- test/context.test.tsx | 52 +++++++-------- test/events.test.tsx | 119 +++++++++++++++++------------------ test/fragment.test.tsx | 26 ++++---- test/host.test.tsx | 80 +++++++++++------------- test/svg.test.tsx | 24 ++++--- 7 files changed, 231 insertions(+), 256 deletions(-) diff --git a/test/async.test.tsx b/test/async.test.tsx index a72408a..a92620b 100644 --- a/test/async.test.tsx +++ b/test/async.test.tsx @@ -1,5 +1,12 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount, dispatchEvent, WebComponent, prop } from "@plusnew/webcomponent"; +import { + createComponent, + mount, + dispatchEvent, + WebComponent, + prop, + define, +} from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -17,26 +24,24 @@ describe("webcomponent", () => { it("async event dispatch", async () => { const { promise, resolve } = Promise.withResolvers(); - const Component = createComponent( - "test-async-dispatch", - class Component extends WebComponent({ onfoo: prop<(evt: CustomEvent) => void>() }) { - #loading = signal(false); - render(this: Component) { - return ( - { - this.#loading.value = true; - try { - await Promise.all(dispatchEvent(this, "foo", { detail: null })); - } catch (_err) {} - this.#loading.value = false; - }} - /> - ); - } - }, - ); + @define("test-async-dispatch") + class Component extends WebComponent({ onfoo: prop<(evt: CustomEvent) => void>() }) { + #loading = signal(false); + render(this: Component) { + return ( + { + this.#loading.value = true; + try { + await Promise.all(dispatchEvent(this, "foo", { detail: null })); + } catch (_err) {} + this.#loading.value = false; + }} + /> + ); + } + } mount(() => promise} />, container); diff --git a/test/base.test.tsx b/test/base.test.tsx index bc7f2a4..5ccdde7 100644 --- a/test/base.test.tsx +++ b/test/base.test.tsx @@ -1,6 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount, prop, WebComponent } from "@plusnew/webcomponent"; -import type { Signal } from "@preact/signals-core"; +import { define, mount, prop, WebComponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -16,16 +15,14 @@ describe("webcomponent", () => { }); it("creates basic component and updating its props", () => { - const Component = createComponent( - "test-base", - class Component extends WebComponent({ foo: prop() }) { - #baz = signal("baz"); + @define("test-base") + class Component extends WebComponent({ foo: prop() }) { + #baz = signal("baz"); - render() { - return `${this.foo}-${this.#baz.value}`; - } - }, - ); + render() { + return `${this.foo}-${this.#baz.value}`; + } + } // @ts-expect-error component with no props given, should be an error ; @@ -52,16 +49,14 @@ describe("webcomponent", () => { }); it("crates array based on given number", () => { - const Component = createComponent( - "test-array", - class Component extends WebComponent({ - amount: prop(), - }) { - render() { - return [...Array(this.amount).keys()].map((value) =>
{value.toString()}
); - } - }, - ); + @define("test-array") + class Component extends WebComponent({ + amount: prop(), + }) { + render() { + return [...Array(this.amount).keys()].map((value) =>
{value.toString()}
); + } + } mount(() => , container); @@ -90,14 +85,12 @@ describe("webcomponent", () => { }); it("crates element if needed", () => { - const Component = createComponent( - "test-placeholder", - class Component extends WebComponent({ show: prop() }) { - render() { - return this.show === true &&
; - } - }, - ); + @define("test-placeholder") + class Component extends WebComponent({ show: prop() }) { + render() { + return this.show === true &&
; + } + } mount(() => , container); @@ -129,25 +122,21 @@ describe("webcomponent", () => { let containerRenderCount = 0; let nestedRenderCount = 0; - const Component = createComponent( - "test-container", - class Component extends WebComponent() { - render() { - containerRenderCount++; - return ; - } - }, - ); - - const NestedComponent = createComponent( - "test-nest", - class Component extends WebComponent() { - render() { - nestedRenderCount++; - return `${foo.value}`; - } - }, - ); + @define("test-container") + class Component extends WebComponent() { + render() { + containerRenderCount++; + return ; + } + } + + @define("test-nest") + class NestedComponent extends WebComponent() { + render() { + nestedRenderCount++; + return `${foo.value}`; + } + } mount(() => , container); @@ -174,35 +163,31 @@ describe("webcomponent", () => { let containerRenderCounter = 0; let nestedRenderCounter = 0; - const NestedComponent = createComponent( - "test-counter-constructor", - class Component extends WebComponent() { - #counter = signal(0); - - render() { - nestedRenderCounter += 1; - return ( - - ); - } - }, - ); - - const Component = createComponent( - "test-container-rerender", - class Component extends WebComponent() { - render() { - containerRenderCounter += 1; - return ; - } - }, - ); + @define("test-counter-constructor") + class NestedComponent extends WebComponent() { + #counter = signal(0); + + render() { + nestedRenderCounter += 1; + return ( + + ); + } + } + + @define("test-container-rerender") + class Component extends WebComponent() { + render() { + containerRenderCounter += 1; + return ; + } + } mount(() => , container); diff --git a/test/context.test.tsx b/test/context.test.tsx index 06a3997..98a82aa 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, WebComponent } from "@plusnew/webcomponent"; +import { mount, createComponent, findParent, WebComponent, define } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; const Provider = createComponent( "test-provider", - class Component extends WebComponent(){ + class Component extends WebComponent() { readonly foo = signal("bar"); render() { @@ -15,7 +15,7 @@ const Provider = createComponent( const Consumer = createComponent( "test-consumer", - class Component extends WebComponent(){ + class Component extends WebComponent() { render() { try { return findParent(Provider).foo.value; @@ -69,14 +69,12 @@ describe("webcomponent", () => { }); it("finds context inline", () => { - const Component = createComponent( - "test-inline", - class Component extends WebComponent(){ - render() { - return {findParent(Provider).foo.value}; - } - }, - ); + @define("test-inline") + class Component extends WebComponent() { + render() { + return {findParent(Provider).foo.value}; + } + } mount(() => , container); @@ -95,22 +93,20 @@ describe("webcomponent", () => { }); it("finds context in event", () => { - const Component = createComponent( - "test-event", - class Component extends WebComponent(){ - render() { - return ( - - { - expect(findParent(Provider).foo.value).to.equal("bar"); - }} - /> - - ); - } - }, - ); + @define("test-event") + class Component extends WebComponent() { + render() { + return ( + + { + expect(findParent(Provider).foo.value).to.equal("bar"); + }} + /> + + ); + } + } mount(() => , container); @@ -136,7 +132,7 @@ describe("webcomponent", () => { it("no context", () => { const Injection = createComponent( "test-injection", - class Component extends WebComponent(){ + class Component extends WebComponent() { render() { return ( diff --git a/test/events.test.tsx b/test/events.test.tsx index 8569910..c4f9035 100644 --- a/test/events.test.tsx +++ b/test/events.test.tsx @@ -1,5 +1,12 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, dispatchEvent, mount, prop, WebComponent } from "@plusnew/webcomponent"; +import { + createComponent, + define, + dispatchEvent, + mount, + prop, + WebComponent, +} from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -16,30 +23,26 @@ describe("webcomponent", () => { it("creates basic component and updating its props", () => { let counter = 0; - const Component = createComponent( - "test-base", - class Component extends WebComponent() { - render() { - return ( - { - counter++; - expect(evt.detail).to.equal("mep"); - }} - /> - ); - } - }, - ); - - const NestedComponent = createComponent( - "test-nested", - class NestedComponent extends WebComponent({ onfoo: prop<(evt: CustomEvent) => void>() }) { - render(this: NestedComponent) { - return - ); - return derefence(this.foo); - } - }, - ); + @define("test-dereference-container") + class Component extends WebComponent() { + #counter = signal(0); + render() { + return ( + { + counter++; + expect(evt.detail).to.eql(this.#counter.value + 1); + this.#counter.value = evt.detail; + }} + /> + ); + } + } + + @define("test-deference") + class NestedComponent extends WebComponent({ + foo: prop(), + onfoo: prop<(value: CustomEvent) => void>(), + }) { + render(this: NestedComponent) { + const derefence = (value: number) => ( + + ); + return derefence(this.foo); + } + } mount(() => , container); diff --git a/test/fragment.test.tsx b/test/fragment.test.tsx index dbea33b..41fcaed 100644 --- a/test/fragment.test.tsx +++ b/test/fragment.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount, WebComponent } from "@plusnew/webcomponent"; +import { define, mount, WebComponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("fragment", () => { @@ -17,19 +17,17 @@ describe("fragment", () => { it("creates basic component with fragment", () => { const baz = signal("foo"); - const Component = createComponent( - "test-base", - class Component extends WebComponent() { - render() { - return ( - <> -
{baz.value}
- - - ); - } - }, - ); + @define("test-base") + class Component extends WebComponent() { + render() { + return ( + <> +
{baz.value}
+ + + ); + } + } mount(() => , container); diff --git a/test/host.test.tsx b/test/host.test.tsx index abdba62..f483908 100644 --- a/test/host.test.tsx +++ b/test/host.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, define, mount, WebComponent } from "@plusnew/webcomponent"; +import { define, mount, WebComponent } from "@plusnew/webcomponent"; import { signal } from "@preact/signals-core"; describe("webcomponent", () => { @@ -86,21 +86,19 @@ describe("webcomponent", () => { }); it("registers input event and updating", async () => { - const Component = createComponent( - "test-input-update", - class Component extends WebComponent() { - #baz = signal("foo"); - - render() { - return ( - (this.#baz.value = (evt.currentTarget as HTMLInputElement).value)} - value={this.#baz.value} - /> - ); - } - }, - ); + @define("test-input-update") + class Component extends WebComponent() { + #baz = signal("foo"); + + render() { + return ( + (this.#baz.value = (evt.currentTarget as HTMLInputElement).value)} + value={this.#baz.value} + /> + ); + } + } mount(() => , container); @@ -125,16 +123,14 @@ describe("webcomponent", () => { }); it("registers input event without updating", async () => { - const Component = createComponent( - "test-input-reject", - class Component extends WebComponent() { - #baz = signal("foo"); + @define("test-input-reject") + class Component extends WebComponent() { + #baz = signal("foo"); - render() { - return null} value={this.#baz.value} />; - } - }, - ); + render() { + return null} value={this.#baz.value} />; + } + } mount(() => , container); @@ -162,24 +158,22 @@ describe("webcomponent", () => { const backgroundColor = signal(null); const fontColor = signal(null); - const Component = createComponent( - "test-style", - class Component extends WebComponent() { - render() { - return backgroundColor.value === null && fontColor.value === null ? ( - - ) : ( - - ); - } - }, - ); + @define("test-style") + class Component extends WebComponent() { + render() { + return backgroundColor.value === null && fontColor.value === null ? ( + + ) : ( + + ); + } + } mount(() => , container); diff --git a/test/svg.test.tsx b/test/svg.test.tsx index a974e47..a9980f2 100644 --- a/test/svg.test.tsx +++ b/test/svg.test.tsx @@ -1,5 +1,5 @@ import { expect } from "@esm-bundle/chai"; -import { createComponent, mount, WebComponent } from "@plusnew/webcomponent"; +import { define, mount, WebComponent } from "@plusnew/webcomponent"; describe("svg", () => { let container: HTMLElement; @@ -14,18 +14,16 @@ describe("svg", () => { }); it("async event dispatch", async () => { - const Component = createComponent( - "test-svg", - class Component extends WebComponent() { - render(this: Component) { - return ( - - - - ); - } - }, - ); + @define("test-svg") + class Component extends WebComponent() { + render(this: Component) { + return ( + + + + ); + } + } mount(() => , container); From 47dcadc3fc6505cc1c0ec0fcbb1a89b88736a003 Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 2 Sep 2026 09:41:24 +0200 Subject: [PATCH 4/7] feat(optional): add optional properties handling --- src/utils.ts | 9 ++++++++- test/base.test.tsx | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 5dcd05f..a9e0444 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,7 +40,14 @@ export type BasePropsType = Omit, ForbiddenHTMLProperties | }; type PropType Signal }> = { - [Prop in keyof T]: ReturnType["value"]; + [Prop in keyof T as undefined extends ReturnType["value"] ? Prop : never]?: Exclude< + ReturnType["value"], + undefined + >; +} & { + [Prop in keyof T as undefined extends ReturnType["value"] ? never : Prop]: ReturnType< + T[Prop] + >["value"]; }; interface IComponent extends HTMLElement { diff --git a/test/base.test.tsx b/test/base.test.tsx index 5ccdde7..b428bf3 100644 --- a/test/base.test.tsx +++ b/test/base.test.tsx @@ -16,7 +16,7 @@ describe("webcomponent", () => { it("creates basic component and updating its props", () => { @define("test-base") - class Component extends WebComponent({ foo: prop() }) { + class Component extends WebComponent({ foo: prop(), bar: prop() }) { #baz = signal("baz"); render() { From 81c545447382692c3436599cc51a9875c806d1c1 Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 2 Sep 2026 11:09:46 +0200 Subject: [PATCH 5/7] fix(props): change to property-descriptor init --- src/index.ts | 17 ++++++++++++++--- src/types.ts | 12 ++++++++++++ src/utils.ts | 43 ++++++++++++++++++------------------------- 3 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/index.ts b/src/index.ts index e0e51dc..64beffb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { effect, Signal, signal } from "@preact/signals-core"; import { reconcile } from "./reconciler/index"; import { ShadowCache } from "./reconciler/utils"; -import type { CustomEvents, ShadowElement } from "./types"; +import type { CustomEvents, PropertyDescriptor, ShadowElement } from "./types"; import { parentsCacheSymbol, active } from "./utils"; export type { ShadowElement } from "./types"; @@ -131,6 +131,17 @@ export function dispatchEvent(): () => Signal { - return () => signal() as Signal; +export function prop(): () => PropertyDescriptor { + return () => { + const signalValue = signal() as Signal; + + return { + get: () => { + return signalValue.value; + }, + set: (value) => { + signalValue.value = value; + }, + }; + }; } diff --git a/src/types.ts b/src/types.ts index f90be7a..be65706 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,18 @@ export function Fragment(props: { children: ShadowElement }) { return props.children; } +export interface PropertyDescriptor { + configurable?: boolean; + enumerable?: boolean; + value?: T; + writable?: boolean; + get?(): T; + set?(v: T): void; +} + +export type PropertyDescriptorType> = + T extends PropertyDescriptor ? R : never; + // type Expect = T; type IsEqual = diff --git a/src/utils.ts b/src/utils.ts index a9e0444..5ada1c7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,11 @@ import { batch, effect, Signal, untracked } from "@preact/signals-core"; import { ShadowCache } from "./reconciler/utils"; -import type { ForbiddenHTMLProperties, ShadowElement } from "./types"; +import type { + ForbiddenHTMLProperties, + PropertyDescriptor, + PropertyDescriptorType, + ShadowElement, +} from "./types"; import { reconcile } from "./reconciler"; const ERROR = "plusnewerror"; @@ -39,15 +44,14 @@ export type BasePropsType = Omit, ForbiddenHTMLProperties | children?: ShadowElement; }; -type PropType Signal }> = { - [Prop in keyof T as undefined extends ReturnType["value"] ? Prop : never]?: Exclude< - ReturnType["value"], - undefined - >; +type PropType PropertyDescriptor }> = { + [ + Prop in keyof T as undefined extends PropertyDescriptorType> ? Prop : never + ]?: Exclude>, undefined>; } & { - [Prop in keyof T as undefined extends ReturnType["value"] ? never : Prop]: ReturnType< - T[Prop] - >["value"]; + [ + Prop in keyof T as undefined extends PropertyDescriptorType> ? never : Prop + ]: PropertyDescriptorType>; }; interface IComponent extends HTMLElement { @@ -55,7 +59,7 @@ interface IComponent extends HTMLElement { disconnectedCallback(): void; } -export function WebComponent Signal } = {}>( +export function WebComponent PropertyDescriptor } = {}>( props?: T, ): abstract new (props: PropType & BasePropsType) => PropType & IComponent { abstract class Component extends HTMLElement implements IComponent { @@ -65,21 +69,10 @@ export function WebComponent Signal } = {} Object.defineProperties( this, Object.fromEntries( - Object.entries(props).map(([key, init]) => { - const signal = init(); - - return [ - key, - { - get: () => { - return signal.value; - }, - set: (value) => { - signal.value = value; - }, - }, - ]; - }), + Object.entries(props).map(([key, propertyDescriptorFactory]) => [ + key, + propertyDescriptorFactory(), + ]), ), ); } From 2566f80d9d99a206de206765c9a7c5d658404ee6 Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 2 Sep 2026 15:50:10 +0200 Subject: [PATCH 6/7] chore: publish types --- src/index.ts | 4 ++-- src/utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 64beffb..3b08398 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,8 @@ import { ShadowCache } from "./reconciler/utils"; import type { CustomEvents, PropertyDescriptor, ShadowElement } from "./types"; import { parentsCacheSymbol, active } from "./utils"; -export type { ShadowElement } from "./types"; -export { active, WebComponent, type BasePropsType } from "./utils"; +export type { ShadowElement, PropertyDescriptor } from "./types"; +export { active, WebComponent, type BasePropsType, type PropType } from "./utils"; export function mount(render: () => ShadowElement, parent: HTMLElement): () => void { const shadowResult: ShadowCache = new ShadowCache(false); diff --git a/src/utils.ts b/src/utils.ts index 5ada1c7..9c6a9c7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -44,7 +44,7 @@ export type BasePropsType = Omit, ForbiddenHTMLProperties | children?: ShadowElement; }; -type PropType PropertyDescriptor }> = { +export type PropType PropertyDescriptor }> = { [ Prop in keyof T as undefined extends PropertyDescriptorType> ? Prop : never ]?: Exclude>, undefined>; From f54a4a03df951e729aeef9c1db55d956e18d844b Mon Sep 17 00:00:00 2001 From: plusgut Date: Thu, 3 Sep 2026 16:38:23 +0200 Subject: [PATCH 7/7] chore: remove unneeded import --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 9c6a9c7..96e12b4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,4 @@ -import { batch, effect, Signal, untracked } from "@preact/signals-core"; +import { batch, effect, untracked } from "@preact/signals-core"; import { ShadowCache } from "./reconciler/utils"; import type { ForbiddenHTMLProperties,