diff --git a/packages/core/src/app/__tests__/onEventHandlers.test.ts b/packages/core/src/app/__tests__/onEventHandlers.test.ts index fb81274d..1a9845a7 100644 --- a/packages/core/src/app/__tests__/onEventHandlers.test.ts +++ b/packages/core/src/app/__tests__/onEventHandlers.test.ts @@ -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); +}); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 0ba78092..d3002802 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -719,6 +719,7 @@ export function createApp(opts: CreateAppStateOptions | 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 = @@ -881,8 +882,22 @@ export function createApp(opts: CreateAppStateOptions | 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; } @@ -934,6 +949,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } } finally { inEventHandlerDepth--; + flushDeferredInlineFatal(); } return true; }