From 903ce0ed4725a83189920fa490cc4f906cbc8682 Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 5 Nov 2025 18:32:46 +0100 Subject: [PATCH 1/3] feat(async-event): add test and type to async-event-collection --- src/index.ts | 4 ++- test/async.test.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 test/async.test.tsx diff --git a/src/index.ts b/src/index.ts index 4ede17a..9cae0a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -176,10 +176,12 @@ export function findParent( export function dispatchEvent< T extends HTMLElement, U extends keyof CustomEvents, ->(target: T, eventName: U, detail: CustomEvents[U]) { +>(target: T, eventName: U, detail: CustomEvents[U]): Promise[] { target.dispatchEvent( new CustomEvent(eventName as string, { detail: detail }), ); + + return []; } export function prop() { diff --git a/test/async.test.tsx b/test/async.test.tsx new file mode 100644 index 0000000..4181157 --- /dev/null +++ b/test/async.test.tsx @@ -0,0 +1,75 @@ +import { expect } from "@esm-bundle/chai"; +import { createComponent, mount, dispatchEvent } from "@plusnew/webcomponent"; +import { signal } from "@preact/signals-core"; + +describe("webcomponent", () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("async event", async () => { + const abortController = new AbortController(); + + const Component = createComponent( + "test-nested", + class Component extends HTMLElement { + onfoo: (evt: CustomEvent) => void; + + #loading = signal(false); + render(this: Component) { + return ( + { + this.#loading.value = true; + try { + await Promise.all(dispatchEvent(this, "foo", null)); + } catch (_err) {} + this.#loading.value = false; + }} + /> + ); + } + }, + ); + + mount( + container, + + new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => { + resolve("done"); + }); + }) + } + />, + ); + + expect(container.childNodes.length).to.equal(1); + + const component = container.childNodes[0] as HTMLElement; + const element = component.shadowRoot?.childNodes[0] as HTMLSpanElement; + + expect(element.classList.contains("loading")).to.eql(false); + + element.dispatchEvent(new MouseEvent("click")); + + expect(element.classList.contains("loading")).to.eql(true); + + await Promise.resolve(); + + expect(element.classList.contains("loading")).to.eql(true); + + abortController.abort("abort"); + + expect(element.classList.contains("loading")).to.eql(false); + }); +}); From 8f3be689e214095c2d78305b0b54e57a115605b5 Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 5 Nov 2025 19:53:05 +0100 Subject: [PATCH 2/3] feat(async): add async handling for dispatch event --- src/index.ts | 13 ++++++-- src/reconciler/host.ts | 71 ++++++++++++----------------------------- src/reconciler/utils.ts | 8 +++++ test/async.test.tsx | 7 ++++ 4 files changed, 47 insertions(+), 52 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9cae0a2..32db876 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,7 +119,10 @@ export function createComponent< const parentsCacheSymbol = Symbol("parentsCache"); export const getParentSymbol = Symbol("getParent"); -export const active = { parentElement: null as null | Element }; +export const active = { + parentElement: null as null | Element, + eventPromises: null as null | Promise[], +}; export function findParent( needle: { new (args: any): T } | string, @@ -177,11 +180,17 @@ export function dispatchEvent< T extends HTMLElement, U extends keyof CustomEvents, >(target: T, eventName: U, detail: CustomEvents[U]): Promise[] { + const previousEventPromises = active.eventPromises; + const eventPromises: Promise[] = []; + active.eventPromises = eventPromises; + target.dispatchEvent( new CustomEvent(eventName as string, { detail: detail }), ); - return []; + active.eventPromises = previousEventPromises; + + return eventPromises; } export function prop() { diff --git a/src/reconciler/host.ts b/src/reconciler/host.ts index fd2e276..68a108e 100644 --- a/src/reconciler/host.ts +++ b/src/reconciler/host.ts @@ -35,6 +35,7 @@ export const hostReconcile: Reconciler = (opt) => { } else { // remove old element opt.shadowCache.remove(); + opt.shadowCache.abortController = new AbortController(); // create new element const element = untracked(() => { @@ -51,20 +52,6 @@ export const hostReconcile: Reconciler = (opt) => { props: {}, children: [], }; - opt.shadowCache.unmount = function () { - delete (this.node as any)[getParentSymbol]; - delete (this as any).unmount; - for (const propKey in (this.value as ShadowHostElement).props) { - if (propKey.startsWith(EVENT_PREFIX)) { - (this.node as any).removeEventListener( - propKey.slice(EVENT_PREFIX.length), - (this.value as ShadowHostElement).props[propKey], - ); - delete (this.value as ShadowHostElement).props[propKey]; - } - } - this.unmount(); - }; elementNeedsAppending = true; } @@ -80,54 +67,38 @@ export const hostReconcile: Reconciler = (opt) => { opt.shadowElement.props[propKey] ) { if (propKey.startsWith(EVENT_PREFIX) === true) { - if (opt.shadowElement.type === "input" && propKey === "oninput") { - const callback = opt.shadowElement.props[propKey]; - opt.shadowElement.props[propKey] = ( - evt: KeyboardEvent, - ...args: any[] - ) => { - const newValue = (evt.currentTarget as HTMLInputElement).value; - - callback(evt, ...args); - - if ( - (opt.shadowElement as ShadowHostElement).props.value !== - newValue - ) { - evt.preventDefault(); - (evt.currentTarget as HTMLInputElement).value = ( - opt.shadowElement as ShadowHostElement - ).props.value; - } - }; - } + if ( + propKey in (opt.shadowCache.value as ShadowHostElement).props === + false + ) { + const eventName = propKey.slice(EVENT_PREFIX.length); - const eventName = propKey.slice(EVENT_PREFIX.length); - if (propKey in (opt.shadowCache.value as ShadowHostElement).props) { - (opt.shadowCache.node as Element).removeEventListener( + (opt.shadowCache.node as Element).addEventListener( eventName, - (opt.shadowCache.value as ShadowHostElement).props[propKey], // @TODO doesnt work for oninput - ); - } + (evt) => { + const shadowElement = opt.shadowElement as ShadowHostElement; + const result = shadowElement.props[propKey](evt); - (opt.shadowCache.node as Element).addEventListener( - eventName, - opt.shadowElement.type === "input" && propKey === "oninput" - ? (evt: KeyboardEvent, ...args: any[]) => { - const shadowElement = opt.shadowElement as ShadowHostElement; + if (shadowElement.type === "input" && propKey === "oninput") { const newValue = (evt.currentTarget as HTMLInputElement) .value; - shadowElement.props[propKey](evt, ...args); - if (shadowElement.props.value !== newValue) { evt.preventDefault(); (evt.currentTarget as HTMLInputElement).value = shadowElement.props.value; } } - : opt.shadowElement.props[propKey], - ); + + if (result instanceof Promise) { + if (active.eventPromises !== null) { + active.eventPromises.push(result); + } + } + }, + { signal: opt.shadowCache.abortController?.signal }, + ); + } } else { untracked(() => { if (propKey === "style") { diff --git a/src/reconciler/utils.ts b/src/reconciler/utils.ts index e73d352..1370b35 100644 --- a/src/reconciler/utils.ts +++ b/src/reconciler/utils.ts @@ -6,6 +6,7 @@ export class ShadowCache { node: Node | null = null; nestedShadows: ShadowCache[] = []; getParentOverwrite: (() => Element) | null = null; + abortController: AbortController | null = null; constructor(value: ShadowElement) { this.value = value; @@ -25,6 +26,13 @@ export class ShadowCache { this.nestedShadows = []; } unmount() { + if (this.abortController !== null) { + this.abortController.abort(); + this.abortController = null; + } + + this.value = false; + for (const nestedShadow of this.nestedShadows) { nestedShadow.unmount(); } diff --git a/test/async.test.tsx b/test/async.test.tsx index 4181157..108e974 100644 --- a/test/async.test.tsx +++ b/test/async.test.tsx @@ -68,7 +68,14 @@ describe("webcomponent", () => { expect(element.classList.contains("loading")).to.eql(true); + const abortPromise = new Promise((resolve) => { + abortController.signal.addEventListener("abort", async () => { + await Promise.resolve(); + resolve(); + }); + }); abortController.abort("abort"); + await abortPromise; expect(element.classList.contains("loading")).to.eql(false); }); From 07376903ad5e86ac2a914e4164ba315c9664278c Mon Sep 17 00:00:00 2001 From: plusgut Date: Wed, 5 Nov 2025 19:58:24 +0100 Subject: [PATCH 3/3] chore(test): improve test-readability --- test/async.test.tsx | 26 +++++--------------------- tsconfig.json | 4 ++-- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/test/async.test.tsx b/test/async.test.tsx index 108e974..72122ba 100644 --- a/test/async.test.tsx +++ b/test/async.test.tsx @@ -15,7 +15,7 @@ describe("webcomponent", () => { }); it("async event", async () => { - const abortController = new AbortController(); + const { promise, resolve } = Promise.withResolvers(); const Component = createComponent( "test-nested", @@ -40,18 +40,7 @@ describe("webcomponent", () => { }, ); - mount( - container, - - new Promise((resolve) => { - abortController.signal.addEventListener("abort", () => { - resolve("done"); - }); - }) - } - />, - ); + mount(container, promise} />); expect(container.childNodes.length).to.equal(1); @@ -68,14 +57,9 @@ describe("webcomponent", () => { expect(element.classList.contains("loading")).to.eql(true); - const abortPromise = new Promise((resolve) => { - abortController.signal.addEventListener("abort", async () => { - await Promise.resolve(); - resolve(); - }); - }); - abortController.abort("abort"); - await abortPromise; + resolve(); + await promise; + await Promise.resolve(); expect(element.classList.contains("loading")).to.eql(false); }); diff --git a/tsconfig.json b/tsconfig.json index e3d6576..c86686e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,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": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "ES2024", /* 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. */ @@ -102,4 +102,4 @@ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ } -} \ No newline at end of file +}