diff --git a/apps/web/src/features/resume/stylesheet/store.test.ts b/apps/web/src/features/resume/stylesheet/store.test.ts index 8eea096fa..4bf4685d1 100644 --- a/apps/web/src/features/resume/stylesheet/store.test.ts +++ b/apps/web/src/features/resume/stylesheet/store.test.ts @@ -741,6 +741,97 @@ describe("stylesheet store runtime", () => { ); }); + it("does not leave Checking stuck when compile rejects for the current edit", async () => { + const runtime = createStylesheetStoreRuntime({ + resumeId: "resume-1", + initial, + resumeData: defaultResumeData, + debounceMs: 0, + compile: () => Promise.reject(new Error("Discarded stale stylesheet compiler result.")), + preflight: vi.fn(), + mutate: vi.fn(), + }); + + runtime.store.getState().setSourceText("edited source"); + await vi.runAllTimersAsync(); + + expect(runtime.store.getState().status).not.toBe("compiling"); + expect(runtime.store.getState().status).toBe("error"); + }); + + it("finishes an edit when refreshIntelligence interleaves through the shared compile client", async () => { + const { createCompileWorkerClient } = await import("./worker-client"); + const listeners = new Map>(); + const fake = { + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn((type: string, listener: EventListener) => { + const bucket = listeners.get(type) ?? new Set(); + bucket.add(listener); + listeners.set(type, bucket); + }), + removeEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.get(type)?.delete(listener); + }), + emit(data: unknown) { + for (const listener of listeners.get("message") ?? []) { + (listener as (event: MessageEvent) => void)(new MessageEvent("message", { data })); + } + }, + }; + const client = createCompileWorkerClient(() => fake); + const runtime = createStylesheetStoreRuntime({ + resumeId: "resume-1", + initial, + resumeData: defaultResumeData, + debounceMs: 0, + compile: client.compile, + preflight: async ({ editGeneration }) => ({ + type: "preflight_result", + requestId: editGeneration, + editGeneration, + result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) }, + }), + mutate: async ({ editGeneration }) => ({ + stylesheet: stylesheet("edited source"), + revision: 4, + renderDataVersion: 7, + editGeneration, + diagnostics: [], + }), + }); + + runtime.store.getState().setSourceText("edited source"); + await vi.runAllTimersAsync(); + expect(runtime.store.getState().status).toBe("compiling"); + expect(fake.postMessage).toHaveBeenCalledTimes(1); + + // Mobile Design remount / accordion reopen refreshes intelligence while the edit is compiling. + runtime.store.getState().refreshIntelligence(); + expect(fake.postMessage).toHaveBeenCalledTimes(2); + + fake.emit({ + type: "compile_result", + requestId: 1, + editGeneration: 1, + program: { languageVersion: 1, rules: [] }, + diagnostics: [], + colorTokens: [], + }); + fake.emit({ + type: "compile_result", + requestId: 2, + editGeneration: 1, + program: { languageVersion: 1, rules: [] }, + diagnostics: [], + colorTokens: [], + }); + await vi.runAllTimersAsync(); + + expect(runtime.store.getState().status).toBe("applied"); + expect(runtime.store.getState().applied.text).toBe("edited source"); + }); + it("terminates both worker clients and clears the store on cleanup", () => { const destroy = vi.fn(); let mutationSignal: AbortSignal | undefined; diff --git a/apps/web/src/features/resume/stylesheet/store.ts b/apps/web/src/features/resume/stylesheet/store.ts index f4d77c2ce..0b05c2622 100644 --- a/apps/web/src/features/resume/stylesheet/store.ts +++ b/apps/web/src/features/resume/stylesheet/store.ts @@ -349,6 +349,9 @@ export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRunti compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree), ); } catch { + if (candidateValidationEpoch !== validationEpoch) return; + if (destroyed || candidate.generation !== store.getState().editGeneration) return; + patch({ status: "error" }); return; } if (candidateValidationEpoch !== validationEpoch) return; diff --git a/apps/web/src/features/resume/stylesheet/worker-client.test.ts b/apps/web/src/features/resume/stylesheet/worker-client.test.ts index 30fb64129..0f1fad7ac 100644 --- a/apps/web/src/features/resume/stylesheet/worker-client.test.ts +++ b/apps/web/src/features/resume/stylesheet/worker-client.test.ts @@ -5,20 +5,33 @@ import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker type Listener = (event: MessageEvent) => void; function worker() { - const listeners = new Set(); + const listeners = new Map>(); return { postMessage: vi.fn(), terminate: vi.fn(), - addEventListener: vi.fn((_type: string, listener: Listener) => listeners.add(listener)), - removeEventListener: vi.fn((_type: string, listener: Listener) => listeners.delete(listener)), + addEventListener: vi.fn((type: string, listener: EventListener) => { + const bucket = listeners.get(type) ?? new Set(); + bucket.add(listener); + listeners.set(type, bucket); + }), + removeEventListener: vi.fn((type: string, listener: EventListener) => { + listeners.get(type)?.delete(listener); + }), emit(data: unknown) { - for (const listener of listeners) listener(new MessageEvent("message", { data })); + for (const listener of listeners.get("message") ?? []) { + (listener as Listener)(new MessageEvent("message", { data })); + } + }, + emitError(event: ErrorEvent) { + for (const listener of listeners.get("error") ?? []) { + listener(event); + } }, }; } describe("stylesheet worker clients", () => { - it("rejects stale compiler results by request id", async () => { + it("resolves older compiler results so callers can generation-check without aborting", async () => { const fake = worker(); const client = createCompileWorkerClient(() => fake); const first = client.compile({ editGeneration: 1 } as never); @@ -27,8 +40,18 @@ describe("stylesheet worker clients", () => { fake.emit({ type: "compile_result", requestId: 1, editGeneration: 1, program: null, diagnostics: [] }); fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] }); - await expect(first).rejects.toThrow("stale"); - await expect(second).resolves.toMatchObject({ requestId: 2 }); + await expect(first).resolves.toMatchObject({ requestId: 1, editGeneration: 1 }); + await expect(second).resolves.toMatchObject({ requestId: 2, editGeneration: 2 }); + }); + + it("rejects pending compiles when the worker reports an error", async () => { + const fake = worker(); + const client = createCompileWorkerClient(() => fake); + const pending = client.compile({ editGeneration: 1 } as never); + + fake.emitError({ message: "Failed to load compiler worker" } as ErrorEvent); + + await expect(pending).rejects.toThrow("Failed to load compiler worker"); }); it("terminates and recreates a timed-out preflight worker", async () => { @@ -68,7 +91,9 @@ describe("stylesheet worker clients", () => { client.warmup(); expect(createWorker).toHaveBeenCalledOnce(); - expect(fake.addEventListener).toHaveBeenCalledOnce(); + expect(fake.addEventListener).toHaveBeenCalledTimes(2); + expect(fake.addEventListener).toHaveBeenCalledWith("message", expect.any(Function)); + expect(fake.addEventListener).toHaveBeenCalledWith("error", expect.any(Function)); expect(fake.postMessage).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/features/resume/stylesheet/worker-client.ts b/apps/web/src/features/resume/stylesheet/worker-client.ts index dc919abf4..c09841bb4 100644 --- a/apps/web/src/features/resume/stylesheet/worker-client.ts +++ b/apps/web/src/features/resume/stylesheet/worker-client.ts @@ -10,12 +10,15 @@ import type { } from "./protocol"; type WorkerListener = (event: MessageEvent) => void; +type WorkerErrorListener = (event: ErrorEvent) => void; export type StylesheetWorker = { postMessage(message: unknown, transfer?: Transferable[]): void; terminate(): void; addEventListener(type: "message", listener: WorkerListener): void; + addEventListener(type: "error", listener: WorkerErrorListener): void; removeEventListener(type: "message", listener: WorkerListener): void; + removeEventListener(type: "error", listener: WorkerErrorListener): void; }; type Pending = { @@ -34,13 +37,17 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker) const request = pending.get(response.requestId); if (!request) return; pending.delete(response.requestId); - if (response.requestId !== latestRequestId) { - request.reject(new Error("Discarded stale stylesheet compiler result.")); - return; - } + // Resolve every in-flight compile. Callers already generation-check; rejecting "stale" + // results aborts the edit pipeline and can leave the editor stuck on Checking. request.resolve(response); }; + const onError: WorkerErrorListener = (event) => { + const error = new Error(event.message || "Stylesheet compiler worker failed to load."); + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); return { compile(input: CompileWorkerInput): Promise { @@ -53,6 +60,7 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker) }, destroy() { worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); worker.terminate(); for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated.")); pending.clear(); @@ -120,10 +128,14 @@ export function createPreflightWorkerClient( pending.delete(response.requestId); request.resolve(response); }; + const onError: WorkerErrorListener = () => { + terminate(); + }; const terminate = () => { if (!worker) return; worker.removeEventListener("message", onMessage); + worker.removeEventListener("error", onError); worker.terminate(); worker = undefined; ready = false; @@ -140,6 +152,7 @@ export function createPreflightWorkerClient( if (readiness) return readiness.promise; worker = createWorker(); worker.addEventListener("message", onMessage); + worker.addEventListener("error", onError); let resolve!: (value: StylesheetWorker) => void; let reject!: (error: Error) => void; const promise = new Promise((resolvePromise, rejectPromise) => {