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
77 changes: 77 additions & 0 deletions packages/core/src/app/__tests__/onEventHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,80 @@ test("onEvent handler failure aborts the current batch before later events commi
assert.equal(backend.stopCalls, 1);
assert.equal(backend.disposeCalls, 1);
});

test("onEvent handler failure faults before same-turn queued updates commit", async () => {
const backend = new StubBackend();
const app = createApp({ backend, initialState: 0 });

app.draw((g) => g.clear());

let updaterRan = false;
app.onEvent((ev) => {
if (ev.kind !== "engine" || ev.event.kind !== "text") return;
app.update((state) => {
updaterRan = true;
return state + 1;
});
throw new Error("boom");
});

const fatals: string[] = [];
app.onEvent((ev) => {
if (ev.kind === "fatal") fatals.push(ev.detail);
});

await app.start();
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [{ kind: "text", timeMs: 1, codepoint: 65 }],
}),
}),
);

await flushMicrotasks(20);

assert.equal(updaterRan, false);
assert.equal(fatals.length, 1);
assert.equal(backend.stopCalls, 1);
assert.equal(backend.disposeCalls, 1);
});

test("fatal handlers run after onEvent handler depth unwinds", async () => {
const backend = new StubBackend();
const app = createApp({ backend, initialState: 0 });
app.draw((g) => g.clear());

app.onEvent((ev) => {
if (ev.kind === "engine" && ev.event.kind === "text") {
throw new Error("boom");
}
});

let fatalHandlerError: unknown = null;
let sawFatal = false;
app.onEvent((ev) => {
if (ev.kind !== "fatal") return;
sawFatal = true;
try {
app.dispose();
} catch (error: unknown) {
fatalHandlerError = error;
}
});

await app.start();
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [{ kind: "text", timeMs: 1, codepoint: 65 }],
}),
}),
);

await flushMicrotasks(20);

assert.equal(sawFatal, true);
assert.equal(fatalHandlerError, null);
assert.equal(backend.disposeCalls >= 1, true);
});
18 changes: 17 additions & 1 deletion packages/core/src/app/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,7 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
let breadcrumbLastConsumptionPath: RuntimeBreadcrumbConsumptionPath | null = null;
let breadcrumbLastAction: RuntimeBreadcrumbAction | null = null;
let breadcrumbEventTracked = false;
let deferredInlineFatal: Readonly<{ code: ZrUiErrorCode; detail: string }> | null = null;

function recomputeRuntimeBreadcrumbCollection(): void {
const next =
Expand Down Expand Up @@ -881,8 +882,22 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
scheduler.enqueue({ kind: "fatal", code, detail });
}

function flushDeferredInlineFatal(): void {
if (deferredInlineFatal === null || inEventHandlerDepth !== 0) return;
const fatal = deferredInlineFatal;
deferredInlineFatal = null;
doFatal(fatal.code, fatal.detail);
}

function fatalNowOrEnqueue(code: ZrUiErrorCode, detail: string): void {
if (scheduler.isExecuting) {
const canFailFastInline = scheduler.isExecuting && !inRender && !inCommit;
if (canFailFastInline && inEventHandlerDepth > 0) {
if (deferredInlineFatal === null) {
deferredInlineFatal = Object.freeze({ code, detail });
}
return;
}
if (canFailFastInline) {
doFatal(code, detail);
return;
}
Expand Down Expand Up @@ -934,6 +949,7 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
}
} finally {
inEventHandlerDepth--;
flushDeferredInlineFatal();
}
return true;
}
Expand Down
Loading