Skip to content

Commit 749084a

Browse files
authored
Merge pull request #146 from plusnew/async_event
Async event
2 parents 31616cb + 0737690 commit 749084a

File tree

5 files changed

+110
-54
lines changed

5 files changed

+110
-54
lines changed

src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,10 @@ export function createComponent<
119119
const parentsCacheSymbol = Symbol("parentsCache");
120120
export const getParentSymbol = Symbol("getParent");
121121

122-
export const active = { parentElement: null as null | Element };
122+
export const active = {
123+
parentElement: null as null | Element,
124+
eventPromises: null as null | Promise<unknown>[],
125+
};
123126

124127
export function findParent<T = Element>(
125128
needle: { new (args: any): T } | string,
@@ -176,10 +179,18 @@ export function findParent<T = Element>(
176179
export function dispatchEvent<
177180
T extends HTMLElement,
178181
U extends keyof CustomEvents<T>,
179-
>(target: T, eventName: U, detail: CustomEvents<T>[U]) {
182+
>(target: T, eventName: U, detail: CustomEvents<T>[U]): Promise<unknown>[] {
183+
const previousEventPromises = active.eventPromises;
184+
const eventPromises: Promise<unknown>[] = [];
185+
active.eventPromises = eventPromises;
186+
180187
target.dispatchEvent(
181188
new CustomEvent(eventName as string, { detail: detail }),
182189
);
190+
191+
active.eventPromises = previousEventPromises;
192+
193+
return eventPromises;
183194
}
184195

185196
export function prop() {

src/reconciler/host.ts

Lines changed: 21 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const hostReconcile: Reconciler = (opt) => {
3535
} else {
3636
// remove old element
3737
opt.shadowCache.remove();
38+
opt.shadowCache.abortController = new AbortController();
3839

3940
// create new element
4041
const element = untracked(() => {
@@ -51,20 +52,6 @@ export const hostReconcile: Reconciler = (opt) => {
5152
props: {},
5253
children: [],
5354
};
54-
opt.shadowCache.unmount = function () {
55-
delete (this.node as any)[getParentSymbol];
56-
delete (this as any).unmount;
57-
for (const propKey in (this.value as ShadowHostElement).props) {
58-
if (propKey.startsWith(EVENT_PREFIX)) {
59-
(this.node as any).removeEventListener(
60-
propKey.slice(EVENT_PREFIX.length),
61-
(this.value as ShadowHostElement).props[propKey],
62-
);
63-
delete (this.value as ShadowHostElement).props[propKey];
64-
}
65-
}
66-
this.unmount();
67-
};
6855

6956
elementNeedsAppending = true;
7057
}
@@ -80,54 +67,38 @@ export const hostReconcile: Reconciler = (opt) => {
8067
opt.shadowElement.props[propKey]
8168
) {
8269
if (propKey.startsWith(EVENT_PREFIX) === true) {
83-
if (opt.shadowElement.type === "input" && propKey === "oninput") {
84-
const callback = opt.shadowElement.props[propKey];
85-
opt.shadowElement.props[propKey] = (
86-
evt: KeyboardEvent,
87-
...args: any[]
88-
) => {
89-
const newValue = (evt.currentTarget as HTMLInputElement).value;
90-
91-
callback(evt, ...args);
92-
93-
if (
94-
(opt.shadowElement as ShadowHostElement).props.value !==
95-
newValue
96-
) {
97-
evt.preventDefault();
98-
(evt.currentTarget as HTMLInputElement).value = (
99-
opt.shadowElement as ShadowHostElement
100-
).props.value;
101-
}
102-
};
103-
}
70+
if (
71+
propKey in (opt.shadowCache.value as ShadowHostElement).props ===
72+
false
73+
) {
74+
const eventName = propKey.slice(EVENT_PREFIX.length);
10475

105-
const eventName = propKey.slice(EVENT_PREFIX.length);
106-
if (propKey in (opt.shadowCache.value as ShadowHostElement).props) {
107-
(opt.shadowCache.node as Element).removeEventListener(
76+
(opt.shadowCache.node as Element).addEventListener(
10877
eventName,
109-
(opt.shadowCache.value as ShadowHostElement).props[propKey], // @TODO doesnt work for oninput
110-
);
111-
}
78+
(evt) => {
79+
const shadowElement = opt.shadowElement as ShadowHostElement;
80+
const result = shadowElement.props[propKey](evt);
11281

113-
(opt.shadowCache.node as Element).addEventListener(
114-
eventName,
115-
opt.shadowElement.type === "input" && propKey === "oninput"
116-
? (evt: KeyboardEvent, ...args: any[]) => {
117-
const shadowElement = opt.shadowElement as ShadowHostElement;
82+
if (shadowElement.type === "input" && propKey === "oninput") {
11883
const newValue = (evt.currentTarget as HTMLInputElement)
11984
.value;
12085

121-
shadowElement.props[propKey](evt, ...args);
122-
12386
if (shadowElement.props.value !== newValue) {
12487
evt.preventDefault();
12588
(evt.currentTarget as HTMLInputElement).value =
12689
shadowElement.props.value;
12790
}
12891
}
129-
: opt.shadowElement.props[propKey],
130-
);
92+
93+
if (result instanceof Promise) {
94+
if (active.eventPromises !== null) {
95+
active.eventPromises.push(result);
96+
}
97+
}
98+
},
99+
{ signal: opt.shadowCache.abortController?.signal },
100+
);
101+
}
131102
} else {
132103
untracked(() => {
133104
if (propKey === "style") {

src/reconciler/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export class ShadowCache {
66
node: Node | null = null;
77
nestedShadows: ShadowCache[] = [];
88
getParentOverwrite: (() => Element) | null = null;
9+
abortController: AbortController | null = null;
910

1011
constructor(value: ShadowElement) {
1112
this.value = value;
@@ -25,6 +26,13 @@ export class ShadowCache {
2526
this.nestedShadows = [];
2627
}
2728
unmount() {
29+
if (this.abortController !== null) {
30+
this.abortController.abort();
31+
this.abortController = null;
32+
}
33+
34+
this.value = false;
35+
2836
for (const nestedShadow of this.nestedShadows) {
2937
nestedShadow.unmount();
3038
}

test/async.test.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect } from "@esm-bundle/chai";
2+
import { createComponent, mount, dispatchEvent } from "@plusnew/webcomponent";
3+
import { signal } from "@preact/signals-core";
4+
5+
describe("webcomponent", () => {
6+
let container: HTMLElement;
7+
8+
beforeEach(() => {
9+
container = document.createElement("div");
10+
document.body.appendChild(container);
11+
});
12+
13+
afterEach(() => {
14+
container.remove();
15+
});
16+
17+
it("async event", async () => {
18+
const { promise, resolve } = Promise.withResolvers<void>();
19+
20+
const Component = createComponent(
21+
"test-nested",
22+
class Component extends HTMLElement {
23+
onfoo: (evt: CustomEvent<null>) => void;
24+
25+
#loading = signal(false);
26+
render(this: Component) {
27+
return (
28+
<span
29+
className={this.#loading.value === true ? "loading" : ""}
30+
onclick={async () => {
31+
this.#loading.value = true;
32+
try {
33+
await Promise.all(dispatchEvent(this, "foo", null));
34+
} catch (_err) {}
35+
this.#loading.value = false;
36+
}}
37+
/>
38+
);
39+
}
40+
},
41+
);
42+
43+
mount(container, <Component onfoo={() => promise} />);
44+
45+
expect(container.childNodes.length).to.equal(1);
46+
47+
const component = container.childNodes[0] as HTMLElement;
48+
const element = component.shadowRoot?.childNodes[0] as HTMLSpanElement;
49+
50+
expect(element.classList.contains("loading")).to.eql(false);
51+
52+
element.dispatchEvent(new MouseEvent("click"));
53+
54+
expect(element.classList.contains("loading")).to.eql(true);
55+
56+
await Promise.resolve();
57+
58+
expect(element.classList.contains("loading")).to.eql(true);
59+
60+
resolve();
61+
await promise;
62+
await Promise.resolve();
63+
64+
expect(element.classList.contains("loading")).to.eql(false);
65+
});
66+
});

tsconfig.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
1414
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
1515
/* Language and Environment */
16-
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
16+
"target": "ES2024", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
1717
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
1818
"jsx": "react-jsx", /* Specify what JSX code is generated. */
1919
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
@@ -102,4 +102,4 @@
102102
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
103103
"skipLibCheck": true /* Skip type checking all .d.ts files. */
104104
}
105-
}
105+
}

0 commit comments

Comments
 (0)