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
+50
View File
@@ -0,0 +1,50 @@
// Browser-only module worker: the one place react-pdf runs in the app. Not importable from Node tests.
import { installStreamAsyncIterator } from "../../lib/streamPolyfill";
import { initBrowserRendering } from "./browserInit";
import { renderInvoicePdf } from "./invoice";
import type { WorkerMessage, WorkerRequest } from "./jobs";
interface WorkerScope {
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
postMessage(message: WorkerMessage, options?: { transfer: Transferable[] }): void;
}
const scope = self as unknown as WorkerScope;
installStreamAsyncIterator();
const post = (message: WorkerMessage, transfer: Transferable[] = []) => scope.postMessage(message, { transfer });
const failure = (id: number | null, err: unknown): WorkerMessage => ({
type: "error",
id,
message: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
const started = initBrowserRendering();
scope.onmessage = async (event) => {
const request = event.data;
if (request?.type !== "render") return;
const { job } = request;
try {
await started.catch(() => initBrowserRendering());
const t0 = performance.now();
const out = await renderInvoicePdf({ model: job.model, prefs: job.prefs });
const ms = Math.round(performance.now() - t0);
// Hand the buffer over instead of copying it; a view onto part of a larger buffer is copied first.
const whole = out.bytes.byteOffset === 0 && out.bytes.byteLength === out.bytes.buffer.byteLength;
const bytes = whole ? out.bytes : out.bytes.slice();
post(
{ type: "result", result: { id: job.id, bytes, pages: out.pages, issues: out.issues, fingerprint: out.fingerprint, ms } },
[bytes.buffer],
);
} catch (err) {
post(failure(job.id, err));
}
};
started.then(
() => post({ type: "ready" }),
(err) => post(failure(null, err)),
);