Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[],
};

export function findParent<T = Element>(
needle: { new (args: any): T } | string,
Expand Down Expand Up @@ -176,10 +179,18 @@ export function findParent<T = Element>(
export function dispatchEvent<
T extends HTMLElement,
U extends keyof CustomEvents<T>,
>(target: T, eventName: U, detail: CustomEvents<T>[U]) {
>(target: T, eventName: U, detail: CustomEvents<T>[U]): Promise<unknown>[] {
const previousEventPromises = active.eventPromises;
const eventPromises: Promise<unknown>[] = [];
active.eventPromises = eventPromises;

target.dispatchEvent(
new CustomEvent(eventName as string, { detail: detail }),
);

active.eventPromises = previousEventPromises;

return eventPromises;
}

export function prop() {
Expand Down
71 changes: 21 additions & 50 deletions src/reconciler/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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;
}
Expand All @@ -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") {
Expand Down
8 changes: 8 additions & 0 deletions src/reconciler/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down
66 changes: 66 additions & 0 deletions test/async.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>();

const Component = createComponent(
"test-nested",
class Component extends HTMLElement {
onfoo: (evt: CustomEvent<null>) => void;

#loading = signal(false);
render(this: Component) {
return (
<span
className={this.#loading.value === true ? "loading" : ""}
onclick={async () => {
this.#loading.value = true;
try {
await Promise.all(dispatchEvent(this, "foo", null));
} catch (_err) {}
this.#loading.value = false;
}}
/>
);
}
},
);

mount(container, <Component onfoo={() => 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);
});
});
4 changes: 2 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
}
}
}
Loading