fix(web): stop custom styles from sticking on Checking (#3283)

Concurrent compile requests from editor intelligence were rejecting
in-flight edit compiles as stale, and the store swallowed that rejection
without leaving compiling. Resolve all compile results and surface
compile failures as an error status so styles can apply again.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-07-31 01:00:45 +02:00
committed by GitHub
co-authored by Cursor Agent
parent 186c400ab7
commit 4a8f87ab8f
4 changed files with 144 additions and 12 deletions
@@ -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<string, Set<EventListener>>();
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", () => { it("terminates both worker clients and clears the store on cleanup", () => {
const destroy = vi.fn(); const destroy = vi.fn();
let mutationSignal: AbortSignal | undefined; let mutationSignal: AbortSignal | undefined;
@@ -349,6 +349,9 @@ export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRunti
compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree), compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree),
); );
} catch { } catch {
if (candidateValidationEpoch !== validationEpoch) return;
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
patch({ status: "error" });
return; return;
} }
if (candidateValidationEpoch !== validationEpoch) return; if (candidateValidationEpoch !== validationEpoch) return;
@@ -5,20 +5,33 @@ import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker
type Listener = (event: MessageEvent) => void; type Listener = (event: MessageEvent) => void;
function worker() { function worker() {
const listeners = new Set<Listener>(); const listeners = new Map<string, Set<EventListener>>();
return { return {
postMessage: vi.fn(), postMessage: vi.fn(),
terminate: vi.fn(), terminate: vi.fn(),
addEventListener: vi.fn((_type: string, listener: Listener) => listeners.add(listener)), addEventListener: vi.fn((type: string, listener: EventListener) => {
removeEventListener: vi.fn((_type: string, listener: Listener) => listeners.delete(listener)), 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) { 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", () => { 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 fake = worker();
const client = createCompileWorkerClient(() => fake); const client = createCompileWorkerClient(() => fake);
const first = client.compile({ editGeneration: 1 } as never); 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: 1, editGeneration: 1, program: null, diagnostics: [] });
fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] }); fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] });
await expect(first).rejects.toThrow("stale"); await expect(first).resolves.toMatchObject({ requestId: 1, editGeneration: 1 });
await expect(second).resolves.toMatchObject({ requestId: 2 }); 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 () => { it("terminates and recreates a timed-out preflight worker", async () => {
@@ -68,7 +91,9 @@ describe("stylesheet worker clients", () => {
client.warmup(); client.warmup();
expect(createWorker).toHaveBeenCalledOnce(); 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(); expect(fake.postMessage).not.toHaveBeenCalled();
}); });
@@ -10,12 +10,15 @@ import type {
} from "./protocol"; } from "./protocol";
type WorkerListener = (event: MessageEvent<unknown>) => void; type WorkerListener = (event: MessageEvent<unknown>) => void;
type WorkerErrorListener = (event: ErrorEvent) => void;
export type StylesheetWorker = { export type StylesheetWorker = {
postMessage(message: unknown, transfer?: Transferable[]): void; postMessage(message: unknown, transfer?: Transferable[]): void;
terminate(): void; terminate(): void;
addEventListener(type: "message", listener: WorkerListener): void; addEventListener(type: "message", listener: WorkerListener): void;
addEventListener(type: "error", listener: WorkerErrorListener): void;
removeEventListener(type: "message", listener: WorkerListener): void; removeEventListener(type: "message", listener: WorkerListener): void;
removeEventListener(type: "error", listener: WorkerErrorListener): void;
}; };
type Pending<T> = { type Pending<T> = {
@@ -34,13 +37,17 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker)
const request = pending.get(response.requestId); const request = pending.get(response.requestId);
if (!request) return; if (!request) return;
pending.delete(response.requestId); pending.delete(response.requestId);
if (response.requestId !== latestRequestId) { // Resolve every in-flight compile. Callers already generation-check; rejecting "stale"
request.reject(new Error("Discarded stale stylesheet compiler result.")); // results aborts the edit pipeline and can leave the editor stuck on Checking.
return;
}
request.resolve(response); 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("message", onMessage);
worker.addEventListener("error", onError);
return { return {
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse> { compile(input: CompileWorkerInput): Promise<CompileWorkerResponse> {
@@ -53,6 +60,7 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker)
}, },
destroy() { destroy() {
worker.removeEventListener("message", onMessage); worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.terminate(); worker.terminate();
for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated.")); for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated."));
pending.clear(); pending.clear();
@@ -120,10 +128,14 @@ export function createPreflightWorkerClient(
pending.delete(response.requestId); pending.delete(response.requestId);
request.resolve(response); request.resolve(response);
}; };
const onError: WorkerErrorListener = () => {
terminate();
};
const terminate = () => { const terminate = () => {
if (!worker) return; if (!worker) return;
worker.removeEventListener("message", onMessage); worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.terminate(); worker.terminate();
worker = undefined; worker = undefined;
ready = false; ready = false;
@@ -140,6 +152,7 @@ export function createPreflightWorkerClient(
if (readiness) return readiness.promise; if (readiness) return readiness.promise;
worker = createWorker(); worker = createWorker();
worker.addEventListener("message", onMessage); worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
let resolve!: (value: StylesheetWorker) => void; let resolve!: (value: StylesheetWorker) => void;
let reject!: (error: Error) => void; let reject!: (error: Error) => void;
const promise = new Promise<StylesheetWorker>((resolvePromise, rejectPromise) => { const promise = new Promise<StylesheetWorker>((resolvePromise, rejectPromise) => {