Replace the HTML preview with a worker-rendered PDF preview

Rendering moves into a module Web Worker behind a latest-wins RenderClient
with a watchdog and a handshake-based main-thread fallback. The preview
shows the real PDF through pdf.js at 2x with zoom, page indicator, audit
issues popover and a focus modal. A4/Letter and margin controls are frozen
into each invoice at issue and can be saved as the default (migration M3).
react-pdf and pdf.js are lazy-loaded; the main chunk shrinks from 1.8 MB
to 0.5 MB. The HTML twin and its styles are removed.
This commit is contained in:
2026-10-04 05:47:19 +05:30
parent a014a907e4
commit cedf4839bd
24 changed files with 1579 additions and 505 deletions
+286
View File
@@ -0,0 +1,286 @@
import type { RenderJob, RenderResult, WorkerMessage, WorkerRequest } from "./jobs";
/** A newer render call replaced this one before it finished (or before it started). */
export class Superseded extends Error {
constructor() {
super("Render superseded by a newer request");
this.name = "Superseded";
}
}
/** The watchdog fired: the worker was terminated and replaced. */
export class RenderTimeout extends Error {
constructor(ms: number) {
super(`The PDF render did not finish within ${Math.round(ms / 1000)} s and was stopped`);
this.name = "RenderTimeout";
}
}
/** The worker could not start (script, WASM or font failure). Rendering must move to the main thread. */
export class WorkerUnavailable extends Error {
constructor(detail: string) {
super(`The render worker could not start: ${detail}`);
this.name = "WorkerUnavailable";
}
}
/** The render itself threw inside the worker. */
export class RenderFailed extends Error {
constructor(message: string, workerStack?: string) {
super(message);
this.name = "RenderFailed";
if (workerStack) this.stack = workerStack;
}
}
/** The part of Worker the client uses, so tests can supply a fake. */
export interface WorkerLike {
postMessage(message: WorkerRequest): void;
terminate(): void;
onmessage: ((event: { data: WorkerMessage }) => void) | null;
onerror: ((event: { message?: string }) => void) | null;
}
export interface RenderClient {
/** Resolves with the newest request's result; an older call rejects with Superseded. */
render(job: RenderJob): Promise<RenderResult>;
dispose(): void;
readonly mode: "worker" | "main";
}
export interface RenderClientOptions {
workerFactory?: () => WorkerLike;
/** Per-job watchdog. react-pdf's paginate loop is synchronous, so a layout bug hangs the worker for good. */
timeoutMs?: number;
}
const defaultWorkerFactory = (): WorkerLike =>
new Worker(new URL("./render.worker.ts", import.meta.url), { type: "module" }) as unknown as WorkerLike;
interface Slot {
job: RenderJob;
resolve: (r: RenderResult) => void;
reject: (e: unknown) => void;
}
interface Running extends Slot {
/** Its caller was already told; the worker cannot be interrupted, so the job just runs out. */
superseded: boolean;
timer: ReturnType<typeof setTimeout>;
}
/**
* Renders in a module worker. Only the newest request matters: a new call rejects the one in flight
* (the worker keeps going, react-pdf cannot be aborted) and replaces any queued one, so after the
* running job finishes at most one job, the newest, runs. The handshake is the worker's `ready`
* message; a start-up error rejects with WorkerUnavailable and there is no timer-based fallback.
*/
export function createRenderClient(opts: RenderClientOptions = {}): RenderClient {
const { workerFactory = defaultWorkerFactory, timeoutMs = 45000 } = opts;
let worker: WorkerLike | null = null;
let ready = false;
let unavailable: WorkerUnavailable | null = null;
let disposed = false;
let running: Running | null = null;
let pending: Slot | null = null;
const stopWorker = () => {
const w = worker;
worker = null;
ready = false;
if (w) {
w.onmessage = null;
w.onerror = null;
w.terminate();
}
};
const settleRunning = (outcome: (slot: Running) => void) => {
const cur = running;
if (!cur) return;
running = null;
clearTimeout(cur.timer);
if (!cur.superseded) outcome(cur);
};
const fail = (err: WorkerUnavailable) => {
unavailable = err;
stopWorker();
settleRunning((s) => s.reject(err));
pending?.reject(err);
pending = null;
};
const pump = () => {
if (!ready || !worker || running || !pending || disposed) return;
const slot = pending;
pending = null;
running = { ...slot, superseded: false, timer: setTimeout(onTimeout, timeoutMs) };
worker.postMessage({ type: "render", job: slot.job });
};
const spawn = () => {
let w: WorkerLike;
try {
w = workerFactory();
} catch (e) {
fail(new WorkerUnavailable(e instanceof Error ? e.message : String(e)));
return;
}
worker = w;
ready = false;
w.onmessage = (event) => {
if (w !== worker) return;
const msg = event.data;
if (msg.type === "ready") {
ready = true;
pump();
} else if (msg.type === "result") {
if (running?.job.id !== msg.result.id) return;
settleRunning((s) => s.resolve(msg.result));
pump();
} else if (msg.id === null) {
if (!ready) fail(new WorkerUnavailable(msg.message));
} else if (running?.job.id === msg.id) {
settleRunning((s) => s.reject(new RenderFailed(msg.message, msg.stack)));
pump();
}
};
w.onerror = (event) => {
if (w !== worker) return;
const detail = event.message || "script error";
if (!ready) return fail(new WorkerUnavailable(detail));
// A crash mid-job: replace the worker so later jobs have a live one.
settleRunning((s) => s.reject(new RenderFailed(detail)));
stopWorker();
spawn();
};
};
function onTimeout() {
settleRunning((s) => s.reject(new RenderTimeout(timeoutMs)));
stopWorker();
spawn();
}
return {
mode: "worker",
render(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
if (unavailable) return Promise.reject(unavailable);
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
if (!worker) spawn();
pump();
});
},
dispose() {
if (disposed) return;
disposed = true;
const err = new Error("The render client was disposed");
settleRunning((s) => s.reject(err));
pending?.reject(err);
pending = null;
stopWorker();
},
};
}
/** Renders one job on this thread. */
export type MainExecutor = (job: RenderJob) => Promise<RenderResult>;
// Loaded on demand so react-pdf stays out of the main bundle unless the worker is unavailable.
const runOnMainThread: MainExecutor = async (job) => {
const [{ initBrowserRendering }, { renderInvoicePdf }] = await Promise.all([import("./browserInit"), import("./invoice")]);
await initBrowserRendering();
// Let pending input and paint run before the long synchronous layout.
await new Promise<void>((r) => setTimeout(r, 0));
const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
return {
id: job.id,
bytes: out.bytes,
pages: out.pages,
issues: out.issues,
fingerprint: out.fingerprint,
ms: Math.round(performance.now() - t0),
};
};
/** Same interface, same latest-wins rule, on the main thread (nothing can interrupt it, so no watchdog). */
export function createMainThreadClient(execute: MainExecutor = runOnMainThread): RenderClient {
let disposed = false;
let running: (Slot & { superseded: boolean }) | null = null;
let pending: Slot | null = null;
const pump = async () => {
if (running || !pending || disposed) return;
const slot = { ...pending, superseded: false };
pending = null;
running = slot;
try {
const result = await execute(slot.job);
if (!slot.superseded) slot.resolve(result);
} catch (e) {
if (!slot.superseded) slot.reject(e);
}
running = null;
void pump();
};
return {
mode: "main",
render(job) {
if (disposed) return Promise.reject(new Error("The render client was disposed"));
return new Promise<RenderResult>((resolve, reject) => {
pending?.reject(new Superseded());
if (running && !running.superseded) {
running.superseded = true;
running.reject(new Superseded());
}
pending = { job, resolve, reject };
void pump();
});
},
dispose() {
disposed = true;
const err = new Error("The render client was disposed");
pending?.reject(err);
pending = null;
},
};
}
/**
* Worker first; the first WorkerUnavailable switches to the main thread for good. `mode` tells the UI
* which one is active (main thread: the preview is paused while typing).
*/
export function createAutoClient(
opts: RenderClientOptions & { mainExecutor?: MainExecutor } = {},
): RenderClient {
const primary = createRenderClient(opts);
let main: RenderClient | null = null;
return {
get mode() {
return main ? "main" : "worker";
},
render(job) {
if (main) return main.render(job);
return primary.render(job).catch((err) => {
if (!(err instanceof WorkerUnavailable)) throw err;
main ??= createMainThreadClient(opts.mainExecutor);
primary.dispose();
return main.render(job);
});
},
dispose() {
primary.dispose();
main?.dispose();
},
};
}