diff --git a/src/index.ts b/src/index.ts index 4ede17a..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, @@ -176,10 +179,18 @@ 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[] { + const previousEventPromises = active.eventPromises; + const eventPromises: Promise[] = []; + active.eventPromises = eventPromises; + target.dispatchEvent( new CustomEvent(eventName as string, { detail: detail }), ); + + 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 new file mode 100644 index 0000000..72122ba --- /dev/null +++ b/test/async.test.tsx @@ -0,0 +1,66 @@ +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 { promise, resolve } = Promise.withResolvers(); + + 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, promise} />); + + 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); + + 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 +}