diff --git a/packages/core/src/app/__tests__/dirtyPlan.controller.test.ts b/packages/core/src/app/__tests__/dirtyPlan.controller.test.ts new file mode 100644 index 00000000..e2a91015 --- /dev/null +++ b/packages/core/src/app/__tests__/dirtyPlan.controller.test.ts @@ -0,0 +1,46 @@ +import { assert, test } from "@rezi-ui/testkit"; +import { + DIRTY_LAYOUT, + DIRTY_RENDER, + DIRTY_VIEW, + buildWidgetRenderPlan, + createDirtyTracker, +} from "../createApp/dirtyPlan.js"; + +test("dirty tracker keeps dirty bits when a newer version supersedes the snapshot", () => { + const tracker = createDirtyTracker(); + + tracker.markDirty(DIRTY_VIEW); + const staleSnapshot = tracker.snapshotVersions(); + tracker.markDirty(DIRTY_VIEW); + tracker.clearConsumedFlags(DIRTY_VIEW, staleSnapshot); + + assert.equal(tracker.getFlags() & DIRTY_VIEW, DIRTY_VIEW); +}); + +test("dirty tracker clears only the matching dirty bits", () => { + const tracker = createDirtyTracker(); + + tracker.markDirty(DIRTY_VIEW | DIRTY_LAYOUT); + const snapshot = tracker.snapshotVersions(); + tracker.markDirty(DIRTY_RENDER); + tracker.clearConsumedFlags(DIRTY_VIEW | DIRTY_LAYOUT, snapshot); + + assert.equal(tracker.getFlags(), DIRTY_RENDER); +}); + +test("widget render plan derives commit/layout intent from dirty flags", () => { + assert.deepEqual(buildWidgetRenderPlan(DIRTY_VIEW, 12), { + commit: true, + layout: false, + checkLayoutStability: true, + nowMs: 12, + }); + + assert.deepEqual(buildWidgetRenderPlan(DIRTY_LAYOUT | DIRTY_RENDER, 27), { + commit: false, + layout: true, + checkLayoutStability: false, + nowMs: 27, + }); +}); diff --git a/packages/core/src/app/__tests__/focusDispatcher.controller.test.ts b/packages/core/src/app/__tests__/focusDispatcher.controller.test.ts new file mode 100644 index 00000000..81b191b2 --- /dev/null +++ b/packages/core/src/app/__tests__/focusDispatcher.controller.test.ts @@ -0,0 +1,59 @@ +import { assert, test } from "@rezi-ui/testkit"; +import { createFocusDispatcher } from "../createApp/focusDispatcher.js"; + +test("focus dispatcher emits only when the focused id changes", () => { + let focusedId: string | null = null; + const seen: Array = []; + const dispatcher = createFocusDispatcher({ + getFocusedId: () => focusedId, + getFocusInfo: () => ({ id: focusedId }), + initialFocusedId: null, + onHandlerError: () => { + throw new Error("unexpected handler error"); + }, + }); + + const unsubscribe = dispatcher.register((info) => { + seen.push(info.id); + }); + + assert.equal(dispatcher.emitIfChanged(), true); + assert.deepEqual(seen, []); + + focusedId = "first"; + assert.equal(dispatcher.emitIfChanged(), true); + assert.deepEqual(seen, ["first"]); + + assert.equal(dispatcher.emitIfChanged(), true); + assert.deepEqual(seen, ["first"]); + + unsubscribe(); + focusedId = "second"; + assert.equal(dispatcher.emitIfChanged(), true); + assert.deepEqual(seen, ["first"]); +}); + +test("focus dispatcher reports handler failures and stops further fan-out", () => { + const focusedId: string | null = "field"; + const errors: unknown[] = []; + const seen: string[] = []; + const dispatcher = createFocusDispatcher({ + getFocusedId: () => focusedId, + getFocusInfo: () => ({ id: focusedId ?? "none" }), + initialFocusedId: null, + onHandlerError: (error: unknown) => { + errors.push(error); + }, + }); + + dispatcher.register(() => { + throw new Error("boom"); + }); + dispatcher.register((info) => { + seen.push(info.id); + }); + + assert.equal(dispatcher.emitIfChanged(), false); + assert.equal(errors.length, 1); + assert.deepEqual(seen, []); +}); diff --git a/packages/core/src/app/__tests__/resilience.test.ts b/packages/core/src/app/__tests__/resilience.test.ts index 3148ce8b..5aa553d1 100644 --- a/packages/core/src/app/__tests__/resilience.test.ts +++ b/packages/core/src/app/__tests__/resilience.test.ts @@ -426,6 +426,43 @@ test("app.run() resolves when app transitions to Faulted", async () => { } }); +test("app.run() detaches signal handlers when start throws synchronously", async () => { + class ThrowingStartBackend extends StubBackend { + override start(): Promise { + this.startCalls++; + this.callLog.push("start"); + throw new Error("start boom"); + } + } + + const backend = new ThrowingStartBackend(); + const app = createApp({ backend, initialState: 0 }); + app.draw((g) => g.clear()); + + const listeners = new Map void>>(); + const fakeProcess = { + on: (signal: string, handler: (...args: unknown[]) => void) => { + const set = listeners.get(signal) ?? new Set<(...args: unknown[]) => void>(); + set.add(handler); + listeners.set(signal, set); + }, + off: (signal: string, handler: (...args: unknown[]) => void) => { + listeners.get(signal)?.delete(handler); + }, + }; + const g = globalThis as { process?: unknown }; + const prevProcess = g.process; + g.process = fakeProcess; + try { + assert.throws(() => app.run(), /backend.start threw: Error: start boom/); + assert.equal(listeners.get("SIGINT")?.size ?? 0, 0); + assert.equal(listeners.get("SIGTERM")?.size ?? 0, 0); + assert.equal(listeners.get("SIGHUP")?.size ?? 0, 0); + } finally { + g.process = prevProcess; + } +}); + test("nested errorBoundary retry state remains isolated", async () => { const backend = new StubBackend(); const app = createApp({ diff --git a/packages/core/src/app/__tests__/runSignals.controller.test.ts b/packages/core/src/app/__tests__/runSignals.controller.test.ts new file mode 100644 index 00000000..d94c6d6b --- /dev/null +++ b/packages/core/src/app/__tests__/runSignals.controller.test.ts @@ -0,0 +1,107 @@ +import { assert, test } from "@rezi-ui/testkit"; +import { createRunSignalController, readProcessLike } from "../createApp/runSignals.js"; + +test("run signal controller detaches listeners and resolves after signal handling", async () => { + const listeners = new Map void>>(); + const proc = { + on: (signal: string, handler: (...args: unknown[]) => void) => { + const set = listeners.get(signal) ?? new Set<(...args: unknown[]) => void>(); + set.add(handler); + listeners.set(signal, set); + }, + off: (signal: string, handler: (...args: unknown[]) => void) => { + listeners.get(signal)?.delete(handler); + }, + }; + const events: string[] = []; + + const controller = createRunSignalController({ + onDetached: () => { + events.push("detached"); + }, + onSignal: async () => { + events.push("signal"); + }, + processLike: proc, + signals: ["SIGINT", "SIGTERM"], + }); + + assert.equal(controller.canRegisterSignals, true); + assert.equal(listeners.get("SIGINT")?.size ?? 0, 1); + assert.equal(listeners.get("SIGTERM")?.size ?? 0, 1); + + for (const handler of listeners.get("SIGINT") ?? []) { + handler("SIGINT"); + } + await controller.promise; + + assert.deepEqual(events, ["detached", "signal"]); + assert.equal(listeners.get("SIGINT")?.size ?? 0, 0); + assert.equal(listeners.get("SIGTERM")?.size ?? 0, 0); +}); + +test("run signal controller settles cleanly without process hooks", async () => { + let detached = 0; + const controller = createRunSignalController({ + onDetached: () => { + detached++; + }, + onSignal: () => { + throw new Error("signal handler should not run without listeners"); + }, + processLike: null, + }); + + assert.equal(controller.canRegisterSignals, false); + controller.settle(); + await controller.promise; + controller.detach(); + + assert.equal(detached, 1); +}); + +test("run signal controller resolves when onSignal throws synchronously", async () => { + const listeners = new Map void>>(); + const proc = { + on: (signal: string, handler: (...args: unknown[]) => void) => { + const set = listeners.get(signal) ?? new Set<(...args: unknown[]) => void>(); + set.add(handler); + listeners.set(signal, set); + }, + off: (signal: string, handler: (...args: unknown[]) => void) => { + listeners.get(signal)?.delete(handler); + }, + }; + const events: string[] = []; + + const controller = createRunSignalController({ + onDetached: () => { + events.push("detached"); + }, + onSignal: () => { + events.push("signal"); + throw new Error("boom"); + }, + processLike: proc, + signals: ["SIGINT"], + }); + + for (const handler of listeners.get("SIGINT") ?? []) { + handler("SIGINT"); + } + await controller.promise; + + assert.deepEqual(events, ["detached", "signal"]); + assert.equal(listeners.get("SIGINT")?.size ?? 0, 0); +}); + +test("readProcessLike ignores non-object process globals", () => { + const g = globalThis as { process?: unknown }; + const prevProcess = g.process; + try { + g.process = 123; + assert.equal(readProcessLike(), null); + } finally { + g.process = prevProcess; + } +}); diff --git a/packages/core/src/app/__tests__/topLevelViewError.test.ts b/packages/core/src/app/__tests__/topLevelViewError.test.ts new file mode 100644 index 00000000..9122e9bb --- /dev/null +++ b/packages/core/src/app/__tests__/topLevelViewError.test.ts @@ -0,0 +1,27 @@ +import { assert, test } from "@rezi-ui/testkit"; +import { ZR_MOD_CTRL, ZR_MOD_SHIFT } from "../../keybindings/keyCodes.js"; +import { isUnhandledCtrlCKeyEvent } from "../createApp/topLevelViewError.js"; + +test("isUnhandledCtrlCKeyEvent matches plain Ctrl+C only", () => { + assert.equal( + isUnhandledCtrlCKeyEvent({ + kind: "key", + timeMs: 1, + key: 67, + mods: ZR_MOD_CTRL, + action: "down", + }), + true, + ); + + assert.equal( + isUnhandledCtrlCKeyEvent({ + kind: "key", + timeMs: 1, + key: 67, + mods: ZR_MOD_CTRL | ZR_MOD_SHIFT, + action: "down", + }), + false, + ); +}); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index 6247b304..0eee7ed5 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -49,7 +49,7 @@ import { routeKeyEvent, setMode, } from "../keybindings/index.js"; -import { ZR_MOD_ALT, ZR_MOD_CTRL, ZR_MOD_META } from "../keybindings/keyCodes.js"; +import { ZR_MOD_CTRL } from "../keybindings/keyCodes.js"; import { type ResponsiveBreakpointThresholds, normalizeBreakpointThresholds, @@ -72,6 +72,24 @@ import type { Theme } from "../theme/theme.js"; import type { ThemeDefinition } from "../theme/tokens.js"; import type { VNode } from "../widgets/types.js"; import { ui } from "../widgets/ui.js"; +import { + DIRTY_LAYOUT, + DIRTY_RENDER, + DIRTY_VIEW, + buildWidgetRenderPlan, + createDirtyTracker, +} from "./createApp/dirtyPlan.js"; +import { createFocusDispatcher } from "./createApp/focusDispatcher.js"; +import { createRunSignalController, readProcessLike } from "./createApp/runSignals.js"; +import { + type TopLevelViewError, + buildTopLevelViewErrorScreen, + captureTopLevelViewError, + isTopLevelQuitEvent, + isTopLevelRetryEvent, + isUnhandledCtrlCKeyEvent, + isUnmodifiedTextQuitEvent, +} from "./createApp/topLevelViewError.js"; import { RawRenderer } from "./rawRenderer.js"; import { type RuntimeBreadcrumbAction, @@ -350,147 +368,12 @@ type WorkItem = /** Event handler registration with deactivation flag. */ type HandlerSlot = Readonly<{ fn: EventHandler; active: { value: boolean } }>; -type FocusHandlerSlot = Readonly<{ fn: FocusChangeHandler; active: { value: boolean } }>; function describeThrown(v: unknown): string { if (v instanceof Error) return `${v.name}: ${v.message}`; return String(v); } -type TopLevelViewError = Readonly<{ - code: "ZRUI_USER_CODE_THROW"; - detail: string; - message: string; - stack?: string; -}>; - -const KEY_Q = 81; -const KEY_R = 82; -const KEY_C = 67; -const KEY_LOWER_Q = 113; -const KEY_LOWER_R = 114; -const CTRL_C_CODEPOINT = 3; - -function captureTopLevelViewError(value: unknown): TopLevelViewError { - if (value instanceof Error) { - return Object.freeze({ - code: "ZRUI_USER_CODE_THROW", - detail: `${value.name}: ${value.message}`, - message: value.message, - ...(typeof value.stack === "string" && value.stack.length > 0 ? { stack: value.stack } : {}), - }); - } - const detail = String(value); - return Object.freeze({ - code: "ZRUI_USER_CODE_THROW", - detail, - message: detail, - }); -} - -function buildTopLevelViewErrorScreen(error: TopLevelViewError): VNode { - const lines = [`Code: ${error.code}`, `Message: ${error.message}`]; - if (error.stack === undefined || error.stack.length === 0) { - lines.push(`Detail: ${error.detail}`); - } - return ui.column({ width: "full", height: "full", justify: "center", align: "center", p: 1 }, [ - ui.box( - { - width: "full", - height: "full", - border: "single", - title: "Runtime Error", - p: 1, - }, - [ - ui.errorDisplay(lines.join("\n"), { - title: "Top-level view() threw", - ...(error.stack === undefined || error.stack.length === 0 - ? {} - : { stack: error.stack, showStack: true }), - }), - ui.callout("Press R to retry, Q to quit", { variant: "warning" }), - ], - ), - ]); -} - -function isUnmodifiedLetterKey(mods: number): boolean { - return (mods & (ZR_MOD_CTRL | ZR_MOD_ALT | ZR_MOD_META)) === 0; -} - -function isTopLevelRetryEvent(ev: ZrevEvent): boolean { - if (ev.kind === "key") { - return ev.action === "down" && isUnmodifiedLetterKey(ev.mods) && ev.key === KEY_R; - } - if (ev.kind === "text") { - return ev.codepoint === KEY_R || ev.codepoint === KEY_LOWER_R; - } - return false; -} - -function isTopLevelQuitEvent(ev: ZrevEvent): boolean { - if (ev.kind === "key") { - return ev.action === "down" && isUnmodifiedLetterKey(ev.mods) && ev.key === KEY_Q; - } - if (ev.kind === "text") { - return ev.codepoint === KEY_Q || ev.codepoint === KEY_LOWER_Q; - } - return false; -} - -function isUnmodifiedTextQuitEvent(ev: ZrevEvent): boolean { - if (ev.kind !== "text") return false; - return ( - ev.codepoint === KEY_Q || ev.codepoint === KEY_LOWER_Q || ev.codepoint === CTRL_C_CODEPOINT - ); -} - -function isUnhandledCtrlCKeyEvent(ev: ZrevEvent): boolean { - if (ev.kind !== "key") return false; - if (ev.action !== "down") return false; - if (ev.key !== KEY_C) return false; - const hasCtrl = (ev.mods & ZR_MOD_CTRL) !== 0; - if (!hasCtrl) return false; - return (ev.mods & (ZR_MOD_ALT | ZR_MOD_META)) === 0; -} - -type ProcessLike = Readonly<{ - on?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined; - off?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined; - removeListener?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined; - exit?: ((code?: number) => void) | undefined; -}>; - -function readProcessLike(): ProcessLike | null { - const processRef = ( - globalThis as { - process?: { - on?: (event: string, handler: (...args: unknown[]) => void) => unknown; - off?: (event: string, handler: (...args: unknown[]) => void) => unknown; - removeListener?: (event: string, handler: (...args: unknown[]) => void) => unknown; - exit?: (code?: number) => void; - }; - } - ).process; - if (!processRef || typeof processRef !== "object") return null; - return processRef; -} - -function removeSignalHandler( - proc: ProcessLike, - signal: string, - handler: (...args: unknown[]) => void, -): void { - if (typeof proc.off === "function") { - proc.off(signal, handler); - return; - } - if (typeof proc.removeListener === "function") { - proc.removeListener(signal, handler); - } -} - /** * Convert a text codepoint to a key code for keybinding matching. * Letters are normalized to uppercase (A-Z = 65-90). @@ -674,18 +557,9 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl const updates = new UpdateQueue(); const handlers: HandlerSlot[] = []; - const focusHandlers: FocusHandlerSlot[] = []; - let lastEmittedFocusId: string | null = null; - - const DIRTY_RENDER = 1 << 0; - const DIRTY_LAYOUT = 1 << 1; - const DIRTY_VIEW = 1 << 2; + const dirtyTracker = createDirtyTracker(); const spinnerTickMinIntervalMs = Math.max(1, Math.floor(1000 / Math.min(config.fpsCap, 8))); - let dirtyFlags = 0; - let dirtyRenderVersion = 0; - let dirtyLayoutVersion = 0; - let dirtyViewVersion = 0; let framesInFlight = 0; let interactiveBudget = 0; let lastSpinnerRenderTickMs = Number.NEGATIVE_INFINITY; @@ -712,39 +586,11 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl const scheduler = new TurnScheduler((items) => processTurn(items)); - type DirtyVersionSnapshot = Readonly<{ render: number; layout: number; view: number }>; - - function snapshotDirtyVersions(): DirtyVersionSnapshot { - return { - render: dirtyRenderVersion, - layout: dirtyLayoutVersion, - view: dirtyViewVersion, - }; - } - - function clearConsumedDirtyFlags(consumedFlags: number, snapshot: DirtyVersionSnapshot): void { - let clearMask = 0; - if ((consumedFlags & DIRTY_RENDER) !== 0 && dirtyRenderVersion === snapshot.render) { - clearMask |= DIRTY_RENDER; - } - if ((consumedFlags & DIRTY_LAYOUT) !== 0 && dirtyLayoutVersion === snapshot.layout) { - clearMask |= DIRTY_LAYOUT; - } - if ((consumedFlags & DIRTY_VIEW) !== 0 && dirtyViewVersion === snapshot.view) { - clearMask |= DIRTY_VIEW; - } - dirtyFlags &= ~clearMask; - } - function markDirty(flags: number, schedule = true): void { // Track when dirty flags are first set for schedule_wait measurement. // This captures time from "render needed" to "render started". - const wasDirty = dirtyFlags !== 0; - dirtyFlags |= flags; - if ((flags & DIRTY_RENDER) !== 0) dirtyRenderVersion++; - if ((flags & DIRTY_LAYOUT) !== 0) dirtyLayoutVersion++; - if ((flags & DIRTY_VIEW) !== 0) dirtyViewVersion++; - if (PERF_ENABLED && !wasDirty && dirtyFlags !== 0 && scheduleWaitStartMs === null) { + const { wasDirty, flags: nextFlags } = dirtyTracker.markDirty(flags); + if (PERF_ENABLED && !wasDirty && nextFlags !== 0 && scheduleWaitStartMs === null) { scheduleWaitStartMs = perfMarkStart("schedule_wait"); } if (!schedule) return; @@ -833,7 +679,14 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl onUserCodeError: (detail) => enqueueFatal("ZRUI_USER_CODE_THROW", detail), collectRuntimeBreadcrumbs: runtimeBreadcrumbsEnabled, }); - lastEmittedFocusId = widgetRenderer.getFocusedId(); + const focusDispatcher = createFocusDispatcher({ + getFocusedId: () => widgetRenderer.getFocusedId(), + getFocusInfo: () => widgetRenderer.getCurrentFocusInfo(), + initialFocusedId: widgetRenderer.getFocusedId(), + onHandlerError: (error: unknown) => { + enqueueFatal("ZRUI_USER_CODE_THROW", `onFocusChange handler threw: ${describeThrown(error)}`); + }, + }); let routeStateUpdater: ((updater: StateUpdater) => void) | null = null; let routerIntegration: RouterIntegration | null = null; @@ -1073,25 +926,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } function emitFocusChangeIfNeeded(): boolean { - const focusedId = widgetRenderer.getFocusedId(); - if (focusedId === lastEmittedFocusId) return true; - lastEmittedFocusId = focusedId; - - const info = widgetRenderer.getCurrentFocusInfo(); - const snapshot: FocusChangeHandler[] = []; - for (const slot of focusHandlers) { - if (slot.active.value) snapshot.push(slot.fn); - } - - for (const fn of snapshot) { - try { - fn(info); - } catch (e: unknown) { - enqueueFatal("ZRUI_USER_CODE_THROW", `onFocusChange handler threw: ${describeThrown(e)}`); - return false; - } - } - return true; + return focusDispatcher.emitIfChanged(); } function doFatal(code: ZrUiErrorCode, detail: string): void { @@ -1503,7 +1338,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl // During stop(), we may still receive a few late event batches, but we must not // submit new frames (backend may be tearing down). if (lifecycleBusy === "stop") return; - if (dirtyFlags === 0) return; + if (dirtyTracker.getFlags() === 0) return; const maxInFlight = config.maxFramesInFlight + (interactiveBudget > 0 ? 1 : 0); if (framesInFlight >= maxInFlight) return; if (mode === null) return; @@ -1514,7 +1349,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl scheduleWaitStartMs = null; } - const dirtyVersionStart = snapshotDirtyVersions(); + const dirtyVersionStart = dirtyTracker.snapshotVersions(); const snapshot = committedState as Readonly; const hooks = { @@ -1545,7 +1380,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl framesInFlight++; if (interactiveBudget > 0) interactiveBudget--; scheduleFrameSettlement(res.inFlight, submitFrameStartMs, buildEndMs); - clearConsumedDirtyFlags(DIRTY_RENDER | DIRTY_LAYOUT | DIRTY_VIEW, dirtyVersionStart); + dirtyTracker.clearConsumedFlags(DIRTY_RENDER | DIRTY_LAYOUT | DIRTY_VIEW, dirtyVersionStart); return; } @@ -1554,7 +1389,8 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl if (!viewport) return; - if ((dirtyFlags & (DIRTY_VIEW | DIRTY_LAYOUT | DIRTY_RENDER)) === 0) return; + const pendingDirtyFlags = dirtyTracker.getFlags(); + if ((pendingDirtyFlags & (DIRTY_VIEW | DIRTY_LAYOUT | DIRTY_RENDER)) === 0) return; // Compute render plan from dirty flags. Render-only turns (e.g., focus change) // skip view/commit/layout. Layout-only turns (e.g., resize without state change) @@ -1562,18 +1398,8 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl // to decide whether relayout is required, instead of forcing layout by default. // First-frame/bootstrap safety is handled inside submitFrame(): it falls back // to full pipeline when committedRoot or layoutTree is null. - const pendingDirtyFlags = dirtyFlags; const frameNowMs = monotonicNowMs(); - const plan: WidgetRenderPlan = { - commit: (pendingDirtyFlags & DIRTY_VIEW) !== 0, - layout: (pendingDirtyFlags & DIRTY_LAYOUT) !== 0, - // Commit turns must always run layout-stability checks when layout is not - // already explicitly dirty; otherwise interactive state updates can render a - // newly-committed tree against stale layout nodes until the next resize. - checkLayoutStability: - (pendingDirtyFlags & DIRTY_LAYOUT) === 0 && (pendingDirtyFlags & DIRTY_VIEW) !== 0, - nowMs: frameNowMs, - }; + const plan: WidgetRenderPlan = buildWidgetRenderPlan(pendingDirtyFlags, frameNowMs); advanceThemeTransitionFrame(); const resilientView: ViewFn = (state) => { @@ -1618,7 +1444,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl let consumedDirtyFlags = DIRTY_RENDER; if (plan.layout) consumedDirtyFlags |= DIRTY_LAYOUT; if (plan.commit) consumedDirtyFlags |= DIRTY_VIEW; - clearConsumedDirtyFlags(consumedDirtyFlags, dirtyVersionStart); + dirtyTracker.clearConsumedFlags(consumedDirtyFlags, dirtyVersionStart); scheduleThemeTransitionContinuation(); } @@ -1809,12 +1635,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl if (inCommit || inRender) { throwCode("ZRUI_REENTRANT_CALL", "onFocusChange: re-entrant call"); } - - const active = { value: true }; - focusHandlers.push({ fn: handler, active }); - return () => { - active.value = false; - }; + return focusDispatcher.register(handler); }, update(updater: StateUpdater): void { @@ -1900,41 +1721,14 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl if (mode === null) throwCode("ZRUI_NO_RENDER_MODE", "run: no render mode selected"); const proc = readProcessLike(); - const addSignalHandler = - proc !== null && typeof proc.on === "function" ? proc.on.bind(proc) : null; - const canRegisterSignals = addSignalHandler !== null; - const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; - const handlers: Array void }>> = - []; - - let runSettled = false; - let resolveRun!: () => void; - const runPromise = new Promise((resolve) => { - resolveRun = resolve; - }); - - const cleanupSignalHandlers = (): void => { - if (!proc) return; - for (const entry of handlers) { - removeSignalHandler(proc, entry.signal, entry.handler); - } - handlers.length = 0; - }; - - const settleRun = (): void => { - if (runSettled) return; - runSettled = true; - cleanupSignalHandlers(); - if (settleActiveRun === settleRun) settleActiveRun = null; - resolveRun(); - }; - - const onSignal = (): void => { - if (runSettled) return; - runSettled = true; - cleanupSignalHandlers(); - if (settleActiveRun === settleRun) settleActiveRun = null; - void (async () => { + let runSettle: (() => void) | null = null; + const runController = createRunSignalController({ + onDetached: () => { + if (runSettle !== null && settleActiveRun === runSettle) { + settleActiveRun = null; + } + }, + onSignal: async () => { try { if (sm.state === "Running") await app.stop(); } catch { @@ -1950,29 +1744,29 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } catch { // ignore } - resolveRun(); - })(); - }; + }, + processLike: proc, + }); + runSettle = runController.settle; + settleActiveRun = runController.settle; - if (canRegisterSignals) { - for (const signal of signals) { - const handler = () => onSignal(); - handlers.push(Object.freeze({ signal, handler })); - addSignalHandler(signal, handler); - } + let startPromise: Promise; + try { + startPromise = app.start(); + } catch (e: unknown) { + runController.detach(); + throw e; } - settleActiveRun = settleRun; - return app.start().then( + return startPromise.then( () => { - if (!canRegisterSignals) { - settleRun(); + if (!runController.canRegisterSignals) { + runController.settle(); } - return runPromise; + return runController.promise; }, (e: unknown) => { - cleanupSignalHandlers(); - if (settleActiveRun === settleRun) settleActiveRun = null; + runController.detach(); throw e; }, ); diff --git a/packages/core/src/app/createApp/dirtyPlan.ts b/packages/core/src/app/createApp/dirtyPlan.ts new file mode 100644 index 00000000..72198dc6 --- /dev/null +++ b/packages/core/src/app/createApp/dirtyPlan.ts @@ -0,0 +1,71 @@ +import type { WidgetRenderPlan } from "../widgetRenderer.js"; + +export const DIRTY_RENDER = 1 << 0; +export const DIRTY_LAYOUT = 1 << 1; +export const DIRTY_VIEW = 1 << 2; + +export type DirtyVersionSnapshot = Readonly<{ + render: number; + layout: number; + view: number; +}>; + +export type DirtyTracker = Readonly<{ + clearConsumedFlags: (consumedFlags: number, snapshot: DirtyVersionSnapshot) => void; + getFlags: () => number; + markDirty: (flags: number) => Readonly<{ wasDirty: boolean; flags: number }>; + snapshotVersions: () => DirtyVersionSnapshot; +}>; + +export function createDirtyTracker(): DirtyTracker { + let dirtyFlags = 0; + let dirtyRenderVersion = 0; + let dirtyLayoutVersion = 0; + let dirtyViewVersion = 0; + + return { + clearConsumedFlags(consumedFlags: number, snapshot: DirtyVersionSnapshot): void { + let clearMask = 0; + if ((consumedFlags & DIRTY_RENDER) !== 0 && dirtyRenderVersion === snapshot.render) { + clearMask |= DIRTY_RENDER; + } + if ((consumedFlags & DIRTY_LAYOUT) !== 0 && dirtyLayoutVersion === snapshot.layout) { + clearMask |= DIRTY_LAYOUT; + } + if ((consumedFlags & DIRTY_VIEW) !== 0 && dirtyViewVersion === snapshot.view) { + clearMask |= DIRTY_VIEW; + } + dirtyFlags &= ~clearMask; + }, + + getFlags(): number { + return dirtyFlags; + }, + + markDirty(flags: number): Readonly<{ wasDirty: boolean; flags: number }> { + const wasDirty = dirtyFlags !== 0; + dirtyFlags |= flags; + if ((flags & DIRTY_RENDER) !== 0) dirtyRenderVersion++; + if ((flags & DIRTY_LAYOUT) !== 0) dirtyLayoutVersion++; + if ((flags & DIRTY_VIEW) !== 0) dirtyViewVersion++; + return { wasDirty, flags: dirtyFlags }; + }, + + snapshotVersions(): DirtyVersionSnapshot { + return { + render: dirtyRenderVersion, + layout: dirtyLayoutVersion, + view: dirtyViewVersion, + }; + }, + }; +} + +export function buildWidgetRenderPlan(dirtyFlags: number, nowMs: number): WidgetRenderPlan { + return { + commit: (dirtyFlags & DIRTY_VIEW) !== 0, + layout: (dirtyFlags & DIRTY_LAYOUT) !== 0, + checkLayoutStability: (dirtyFlags & DIRTY_LAYOUT) === 0 && (dirtyFlags & DIRTY_VIEW) !== 0, + nowMs, + }; +} diff --git a/packages/core/src/app/createApp/focusDispatcher.ts b/packages/core/src/app/createApp/focusDispatcher.ts new file mode 100644 index 00000000..d1e55d69 --- /dev/null +++ b/packages/core/src/app/createApp/focusDispatcher.ts @@ -0,0 +1,57 @@ +export type FocusDispatcher = Readonly<{ + emitIfChanged: () => boolean; + getLastEmittedId: () => string | null; + register: (handler: (info: T) => void) => () => void; +}>; + +type FocusHandlerSlot = Readonly<{ fn: (info: T) => void; active: { value: boolean } }>; + +type CreateFocusDispatcherOptions = Readonly<{ + getFocusedId: () => string | null; + getFocusInfo: () => T; + initialFocusedId: string | null; + onHandlerError: (error: unknown) => void; +}>; + +export function createFocusDispatcher( + options: CreateFocusDispatcherOptions, +): FocusDispatcher { + const handlers: FocusHandlerSlot[] = []; + let lastEmittedId = options.initialFocusedId; + + return { + emitIfChanged(): boolean { + const focusedId = options.getFocusedId(); + if (focusedId === lastEmittedId) return true; + lastEmittedId = focusedId; + + const info = options.getFocusInfo(); + const snapshot: Array<(info: T) => void> = []; + for (const slot of handlers) { + if (slot.active.value) snapshot.push(slot.fn); + } + + for (const fn of snapshot) { + try { + fn(info); + } catch (error: unknown) { + options.onHandlerError(error); + return false; + } + } + return true; + }, + + getLastEmittedId(): string | null { + return lastEmittedId; + }, + + register(handler: (info: T) => void): () => void { + const active = { value: true }; + handlers.push({ fn: handler, active }); + return () => { + active.value = false; + }; + }, + }; +} diff --git a/packages/core/src/app/createApp/runSignals.ts b/packages/core/src/app/createApp/runSignals.ts new file mode 100644 index 00000000..c1f1ced6 --- /dev/null +++ b/packages/core/src/app/createApp/runSignals.ts @@ -0,0 +1,111 @@ +export type ProcessLike = Readonly<{ + on?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined; + off?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined; + removeListener?: ((event: string, handler: (...args: unknown[]) => void) => unknown) | undefined; + exit?: ((code?: number) => void) | undefined; +}>; + +export type RunSignalController = Readonly<{ + canRegisterSignals: boolean; + detach: () => void; + promise: Promise; + settle: () => void; +}>; + +type CreateRunSignalControllerOptions = Readonly<{ + onDetached?: (() => void) | undefined; + onSignal: () => Promise | void; + processLike: ProcessLike | null; + signals?: readonly string[]; +}>; + +const DEFAULT_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + +export function readProcessLike(): ProcessLike | null { + const processRef = ( + globalThis as { + process?: { + on?: (event: string, handler: (...args: unknown[]) => void) => unknown; + off?: (event: string, handler: (...args: unknown[]) => void) => unknown; + removeListener?: (event: string, handler: (...args: unknown[]) => void) => unknown; + exit?: (code?: number) => void; + }; + } + ).process; + if (!processRef || typeof processRef !== "object") return null; + return processRef; +} + +export function removeSignalHandler( + proc: ProcessLike, + signal: string, + handler: (...args: unknown[]) => void, +): void { + if (typeof proc.off === "function") { + proc.off(signal, handler); + return; + } + if (typeof proc.removeListener === "function") { + proc.removeListener(signal, handler); + } +} + +export function createRunSignalController( + options: CreateRunSignalControllerOptions, +): RunSignalController { + const proc = options.processLike; + const addSignalHandler = + proc !== null && typeof proc.on === "function" ? proc.on.bind(proc) : null; + const listeners: Array void }>> = []; + let detached = false; + let settled = false; + + let resolvePromise!: () => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const detach = (): void => { + if (detached) return; + detached = true; + if (proc) { + for (const entry of listeners) { + removeSignalHandler(proc, entry.signal, entry.handler); + } + } + listeners.length = 0; + options.onDetached?.(); + }; + + const settle = (): void => { + if (settled) return; + settled = true; + detach(); + resolvePromise(); + }; + + if (addSignalHandler !== null) { + for (const signal of options.signals ?? DEFAULT_SIGNALS) { + const handler = () => { + if (settled) return; + settled = true; + detach(); + void Promise.resolve() + .then(() => options.onSignal()) + .catch(() => undefined) + .finally(() => { + resolvePromise(); + }); + }; + listeners.push(Object.freeze({ signal, handler })); + addSignalHandler(signal, handler); + } + } + + return { + canRegisterSignals: addSignalHandler !== null, + detach, + promise, + settle, + }; +} diff --git a/packages/core/src/app/createApp/topLevelViewError.ts b/packages/core/src/app/createApp/topLevelViewError.ts new file mode 100644 index 00000000..f1734fb6 --- /dev/null +++ b/packages/core/src/app/createApp/topLevelViewError.ts @@ -0,0 +1,102 @@ +import type { ZrevEvent } from "../../events.js"; +import { ZR_MOD_ALT, ZR_MOD_CTRL, ZR_MOD_META, ZR_MOD_SHIFT } from "../../keybindings/keyCodes.js"; +import type { VNode } from "../../widgets/types.js"; +import { ui } from "../../widgets/ui.js"; + +export interface TopLevelViewError { + readonly code: "ZRUI_USER_CODE_THROW"; + readonly detail: string; + readonly message: string; + readonly stack?: string; +} + +const KEY_Q = 81; +const KEY_R = 82; +const KEY_C = 67; +const KEY_LOWER_Q = 113; +const KEY_LOWER_R = 114; +const CTRL_C_CODEPOINT = 3; + +function isUnmodifiedLetterKey(mods: number): boolean { + return (mods & (ZR_MOD_CTRL | ZR_MOD_ALT | ZR_MOD_META)) === 0; +} + +export function captureTopLevelViewError(value: unknown): TopLevelViewError { + if (value instanceof Error) { + return Object.freeze({ + code: "ZRUI_USER_CODE_THROW", + detail: `${value.name}: ${value.message}`, + message: value.message, + ...(typeof value.stack === "string" && value.stack.length > 0 ? { stack: value.stack } : {}), + }); + } + const detail = String(value); + return Object.freeze({ + code: "ZRUI_USER_CODE_THROW", + detail, + message: detail, + }); +} + +export function buildTopLevelViewErrorScreen(error: TopLevelViewError): VNode { + const lines = [`Code: ${error.code}`, `Message: ${error.message}`]; + if (error.stack === undefined || error.stack.length === 0) { + lines.push(`Detail: ${error.detail}`); + } + return ui.column({ width: "full", height: "full", justify: "center", align: "center", p: 1 }, [ + ui.box( + { + width: "full", + height: "full", + border: "single", + title: "Runtime Error", + p: 1, + }, + [ + ui.errorDisplay(lines.join("\n"), { + title: "Top-level view() threw", + ...(error.stack === undefined || error.stack.length === 0 + ? {} + : { stack: error.stack, showStack: true }), + }), + ui.callout("Press R to retry, Q to quit", { variant: "warning" }), + ], + ), + ]); +} + +export function isTopLevelRetryEvent(ev: ZrevEvent): boolean { + if (ev.kind === "key") { + return ev.action === "down" && isUnmodifiedLetterKey(ev.mods) && ev.key === KEY_R; + } + if (ev.kind === "text") { + return ev.codepoint === KEY_R || ev.codepoint === KEY_LOWER_R; + } + return false; +} + +export function isTopLevelQuitEvent(ev: ZrevEvent): boolean { + if (ev.kind === "key") { + return ev.action === "down" && isUnmodifiedLetterKey(ev.mods) && ev.key === KEY_Q; + } + if (ev.kind === "text") { + return ev.codepoint === KEY_Q || ev.codepoint === KEY_LOWER_Q; + } + return false; +} + +export function isUnmodifiedTextQuitEvent(ev: ZrevEvent): boolean { + if (ev.kind !== "text") return false; + return ( + ev.codepoint === KEY_Q || ev.codepoint === KEY_LOWER_Q || ev.codepoint === CTRL_C_CODEPOINT + ); +} + +export function isUnhandledCtrlCKeyEvent(ev: ZrevEvent): boolean { + if (ev.kind !== "key") return false; + if (ev.action !== "down") return false; + if (ev.key !== KEY_C) return false; + const hasCtrl = (ev.mods & ZR_MOD_CTRL) !== 0; + if (!hasCtrl) return false; + return (ev.mods & (ZR_MOD_SHIFT | ZR_MOD_ALT | ZR_MOD_META)) === 0; +}