diff --git a/.changeset/brave-workers-fallback.md b/.changeset/brave-workers-fallback.md
new file mode 100644
index 00000000..540d4205
--- /dev/null
+++ b/.changeset/brave-workers-fallback.md
@@ -0,0 +1,5 @@
+---
+"@sheetwrite/core": patch
+---
+
+Fall back observably to the main-thread renderer when a paint worker cannot initialize, cannot create a 2D context, or loses its context. The public worker acknowledgement type now includes `ready` and `fatal` lifecycle messages.
diff --git a/docs/src/content/docs/api/core-worker.md b/docs/src/content/docs/api/core-worker.md
index 5cabfba6..5f1f0fed 100644
--- a/docs/src/content/docs/api/core-worker.md
+++ b/docs/src/content/docs/api/core-worker.md
@@ -24,5 +24,5 @@ Source entry: `packages/core/src/worker.ts`
### Types 1
+
+
-
-type
+```ts generated
+{ type: "ready" }
+```
+
+
+
```ts generated
-type: "painted"
+{ type: "fatal"; reason: string }
```
-
+
+
+
+```ts generated
+{ type: "painted" }
+```
+
+
## Declaration
@@ -32,9 +44,17 @@ type: "painted"
View full TypeScript declaration
```ts generated
-export type WorkerAcknowledgement = {
- type: "painted";
-};
+export type WorkerAcknowledgement =
+ | {
+ type: "ready";
+ }
+ | {
+ type: "fatal";
+ reason: string;
+ }
+ | {
+ type: "painted";
+ };
```
diff --git a/docs/src/generated/docs-contract.json b/docs/src/generated/docs-contract.json
index ada5e87d..c3dd167c 100644
--- a/docs/src/generated/docs-contract.json
+++ b/docs/src/generated/docs-contract.json
@@ -25,7 +25,7 @@
"entryPointCount": 18,
"exportCount": 468,
"symbolPageCount": 468,
- "apiSha256": "9cc3fe343edebb80e5eb431b802655092d5ccd02d94ab4dfab76127c312d6192",
+ "apiSha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb",
"compatibilitySha256": "3dc63d06901349f4edd5b2b5bc332b87cdbecacc54d303c85eb2b0b1354ed01a",
"formulaContractSha256": "c19bbb023fd08bd378ca2d0b82761fa1b203e29f93a5d4b32bf4a2e6ce4affaf"
}
diff --git a/docs/src/generated/public-api.json b/docs/src/generated/public-api.json
index bb518e87..075a1607 100644
--- a/docs/src/generated/public-api.json
+++ b/docs/src/generated/public-api.json
@@ -6610,7 +6610,7 @@
"owners": [
"src/worker.ts"
],
- "source": "src/worker.ts#L256",
+ "source": "src/worker.ts#L259",
"jsDocTags": [],
"documentation": "Build the worker-side protocol handler. Keeping the mutable render state\ninside the returned closure lets tests exercise the real message contract\nwithout booting a browser Worker.",
"memberDocs": []
@@ -6618,13 +6618,13 @@
{
"name": "WorkerAcknowledgement",
"kind": "type",
- "signature": "export type WorkerAcknowledgement = { type: \"painted\" };",
+ "signature": "export type WorkerAcknowledgement = | { type: \"ready\" } | { type: \"fatal\"; reason: string } | { type: \"painted\" };",
"owners": [
"src/worker.ts"
],
"source": "src/worker.ts#L249",
"jsDocTags": [],
- "documentation": "Acknowledgement posted back to the sender after a frame actually painted.",
+ "documentation": "Lifecycle and frame acknowledgements posted back to the sender.",
"memberDocs": []
}
]
diff --git a/packages/core/src/worker-renderer.ts b/packages/core/src/worker-renderer.ts
index 125434ac..c125faae 100644
--- a/packages/core/src/worker-renderer.ts
+++ b/packages/core/src/worker-renderer.ts
@@ -42,6 +42,9 @@ interface SharedRegion {
generation: number;
}
+// Cold module loading and OffscreenCanvas startup can take seconds on slower
+// devices; ten seconds tolerates that path without leaving a blank grid forever.
+const WORKER_READY_TIMEOUT_MS = 10_000;
const SHARED_REGION_COUNT = 2;
const SHARED_HEADER_INTS = 2;
const SHARED_HEADER_BYTES = SHARED_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
@@ -114,6 +117,7 @@ export class WorkerRenderer implements Renderer {
private readonly sharedRegions: Array
= new Array(SHARED_REGION_COUNT);
private failed = false;
private frameGeneration = 0;
+ private readinessTimeout: number | undefined;
constructor(
private readonly workerUrl?: string | URL,
@@ -140,6 +144,9 @@ export class WorkerRenderer implements Renderer {
try {
const offscreen = canvas.transferControlToOffscreen();
+ this.readinessTimeout = window.setTimeout(() => {
+ this.fail(new Error("Sheetwrite: Paint worker did not become ready"));
+ }, WORKER_READY_TIMEOUT_MS);
worker.postMessage({ type: "init", canvas: offscreen, theme }, [offscreen]);
host.appendChild(canvas);
this.canvas = canvas;
@@ -298,6 +305,8 @@ export class WorkerRenderer implements Renderer {
}
destroy(): void {
+ window.clearTimeout(this.readinessTimeout);
+ this.readinessTimeout = undefined;
const worker = this.worker;
if (worker) {
worker.removeEventListener("error", this.onWorkerError);
@@ -312,31 +321,45 @@ export class WorkerRenderer implements Renderer {
}
private readonly onWorkerError = (event: ErrorEvent): void => {
- if (this.failed) return;
- this.failed = true;
event.preventDefault();
const error =
event.error instanceof Error
? event.error
: new Error(event.message || "Sheetwrite: Worker renderer failed to load");
- const onFailure = this.onFailure;
- this.destroy();
- onFailure?.(error);
+ this.fail(error);
};
private readonly onWorkerMessage = (event: MessageEvent): void => {
- if (
- event.data === null ||
- typeof event.data !== "object" ||
- !("type" in event.data) ||
- event.data.type !== "painted"
- ) {
- return;
+ const data = event.data;
+ if (data === null || typeof data !== "object" || !("type" in data)) return;
+ switch (data.type) {
+ case "ready":
+ window.clearTimeout(this.readinessTimeout);
+ this.readinessTimeout = undefined;
+ break;
+ case "fatal": {
+ const reason =
+ "reason" in data && typeof data.reason === "string"
+ ? data.reason
+ : "Sheetwrite: Paint worker reported a fatal failure";
+ this.fail(new Error(reason));
+ break;
+ }
+ case "painted":
+ this.frameGeneration++;
+ if (this.canvas) this.canvas.dataset.workerFrame = String(this.frameGeneration);
+ break;
}
- this.frameGeneration++;
- if (this.canvas) this.canvas.dataset.workerFrame = String(this.frameGeneration);
};
+ private fail(error: unknown): void {
+ if (this.failed) return;
+ this.failed = true;
+ const onFailure = this.onFailure;
+ this.destroy();
+ onFailure?.(error);
+ }
+
private canUseSharedMemory(): boolean {
return (
typeof SharedArrayBuffer === "function" &&
diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts
index 96f5e1c6..cfba9468 100644
--- a/packages/core/src/worker.ts
+++ b/packages/core/src/worker.ts
@@ -245,8 +245,11 @@ function paintPanesFrame(state: WorkerRuntimeState, msg: PanesMessage): boolean
return true;
}
-/** Acknowledgement posted back to the sender after a frame actually painted. */
-export type WorkerAcknowledgement = { type: "painted" };
+/** Lifecycle and frame acknowledgements posted back to the sender. */
+export type WorkerAcknowledgement =
+ | { type: "ready" }
+ | { type: "fatal"; reason: string }
+ | { type: "painted" };
/**
* Build the worker-side protocol handler. Keeping the mutable render state
@@ -271,17 +274,36 @@ export function createWorkerMessageHandler(
const acknowledgeFrame = (painted: boolean): void => {
if (painted) postAcknowledgement({ type: "painted" });
};
+ let fatal = false;
+ const reportFatal = (reason: string): void => {
+ if (fatal) return;
+ fatal = true;
+ postAcknowledgement({ type: "fatal", reason });
+ };
return (input: unknown): void => {
if (input === null || typeof input !== "object" || !("type" in input)) return;
const msg = input as WorkerMessage;
switch (msg.type) {
- case "init":
+ case "init": {
state.canvas = msg.canvas;
state.ctx = state.canvas.getContext("2d", { alpha: false });
state.theme = msg.theme;
state.lastViewport = null;
+ if (!state.ctx) {
+ reportFatal("Sheetwrite: Paint worker could not acquire a 2D context");
+ break;
+ }
+ // A lost canvas invalidates the previous frame assumed by scroll blits;
+ // falling back is safer than attempting restoration from stale pixels.
+ state.canvas.addEventListener(
+ "contextlost",
+ () => reportFatal("Sheetwrite: Paint worker lost its 2D context"),
+ { once: true },
+ );
+ postAcknowledgement({ type: "ready" });
break;
+ }
case "layout":
state.layout = msg.layout;
state.lastViewport = null;
diff --git a/packages/core/test/worker-fallback.test.ts b/packages/core/test/worker-fallback.test.ts
index 7dfafb3c..663e86a5 100644
--- a/packages/core/test/worker-fallback.test.ts
+++ b/packages/core/test/worker-fallback.test.ts
@@ -1,7 +1,8 @@
-import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "bun:test";
import { GridImpl, initSheetwrite } from "../src/grid.js";
import { installCanvasTestStubs } from "../src/testing.js";
import type { GridEvents } from "../src/types.js";
+import { createWorkerMessageHandler } from "../src/worker.js";
import { makeColumnarData, makeWorkbook } from "./fixtures.js";
beforeAll(async () => {
@@ -217,6 +218,140 @@ describe("worker renderer fallback observability", () => {
}
});
+ it("falls back exactly once when the worker cannot acquire a 2D context", async () => {
+ const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker");
+ const transferDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLCanvasElement.prototype,
+ "transferControlToOffscreen",
+ );
+ let terminations = 0;
+
+ class NullContextWorker extends EventTarget {
+ private readonly handleMessage = createWorkerMessageHandler((message) => {
+ this.dispatchEvent(new MessageEvent("message", { data: message }));
+ });
+
+ postMessage(message: unknown): void {
+ queueMicrotask(() => this.handleMessage(message));
+ }
+
+ terminate(): void {
+ terminations += 1;
+ }
+ }
+
+ const offscreen = new EventTarget();
+ Object.defineProperties(offscreen, {
+ width: { value: 0, writable: true },
+ height: { value: 0, writable: true },
+ getContext: { value: () => null },
+ });
+ Object.defineProperty(globalThis, "Worker", {
+ configurable: true,
+ value: NullContextWorker,
+ });
+ Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", {
+ configurable: true,
+ value: () => offscreen,
+ });
+
+ try {
+ const host = mountHost();
+ const events: Array = [];
+ const grid = new GridImpl(host, {
+ workbook: makeWorkbook(5),
+ data: makeColumnarData(5),
+ renderer: "worker",
+ workerUrl: "/worker-without-context.js",
+ });
+ grid.on("renderer-fallback", (event) => events.push(event));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(grid.rendererKind()).toBe("canvas");
+ expect(host.querySelectorAll("canvas")).toHaveLength(1);
+ expect(events).toHaveLength(1);
+ expect(terminations).toBe(1);
+
+ grid.destroy();
+ } finally {
+ if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor);
+ else Reflect.deleteProperty(globalThis, "Worker");
+ if (transferDescriptor) {
+ Object.defineProperty(
+ HTMLCanvasElement.prototype,
+ "transferControlToOffscreen",
+ transferDescriptor,
+ );
+ } else {
+ Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen");
+ }
+ }
+ });
+
+ it("falls back exactly once when the worker never acknowledges initialization", async () => {
+ jest.useFakeTimers();
+ const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker");
+ const transferDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLCanvasElement.prototype,
+ "transferControlToOffscreen",
+ );
+ let terminations = 0;
+
+ class SilentWorker extends EventTarget {
+ postMessage(): void {}
+
+ terminate(): void {
+ terminations += 1;
+ }
+ }
+
+ Object.defineProperty(globalThis, "Worker", {
+ configurable: true,
+ value: SilentWorker,
+ });
+ Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", {
+ configurable: true,
+ value: () => new EventTarget(),
+ });
+
+ try {
+ const host = mountHost();
+ const events: Array = [];
+ const grid = new GridImpl(host, {
+ workbook: makeWorkbook(5),
+ data: makeColumnarData(5),
+ renderer: "worker",
+ workerUrl: "/worker-that-never-replies.js",
+ });
+ grid.on("renderer-fallback", (event) => events.push(event));
+ expect(grid.rendererKind()).toBe("worker");
+
+ jest.advanceTimersByTime(30_000);
+ await Promise.resolve();
+
+ expect(grid.rendererKind()).toBe("canvas");
+ expect(host.querySelectorAll("canvas")).toHaveLength(1);
+ expect(events).toHaveLength(1);
+ expect(terminations).toBe(1);
+
+ grid.destroy();
+ } finally {
+ jest.useRealTimers();
+ if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor);
+ else Reflect.deleteProperty(globalThis, "Worker");
+ if (transferDescriptor) {
+ Object.defineProperty(
+ HTMLCanvasElement.prototype,
+ "transferControlToOffscreen",
+ transferDescriptor,
+ );
+ } else {
+ Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen");
+ }
+ }
+ });
+
it("reports canvas and emits nothing for the default renderer", async () => {
const workbook = makeWorkbook(5);
const events: unknown[] = [];
diff --git a/packages/core/test/worker-renderer.test.ts b/packages/core/test/worker-renderer.test.ts
index 69fe682e..37c2297f 100644
--- a/packages/core/test/worker-renderer.test.ts
+++ b/packages/core/test/worker-renderer.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "bun:test";
+import { describe, expect, it, jest } from "bun:test";
import type { PanePaint, RenderLayout, Theme, VisibleWindowView } from "../src/types.js";
import { createWorkerMessageHandler } from "../src/worker.js";
import { WorkerRenderer } from "../src/worker-renderer.js";
@@ -43,6 +43,23 @@ function latestWorker(): RecordingWorker {
return worker;
}
+const TEST_THEME: Theme = {
+ font: "12px sans-serif",
+ bg: "#fff",
+ fg: "#111",
+ gridLine: "#ddd",
+ headerBg: "#eee",
+ headerFg: "#222",
+ selection: "#def",
+ selectionBorder: "#08f",
+ rowHeight: 20,
+ headerHeight: 24,
+ rowHeaderWidth: 40,
+ searchMatch: "#ff0",
+ searchActiveMatch: "#fa0",
+ highlight: "#cfc",
+};
+
interface SharedPaintPost {
type: "paintPackedShared";
shared: {
@@ -436,17 +453,60 @@ describe("WorkerRenderer", () => {
expect(view.stringPoolUpdateIds).toHaveLength(1);
});
+ it("reports initialization failure and one fatal context-loss outcome", () => {
+ const nullContextAcknowledgements: unknown[] = [];
+ const nullContextCanvas = new EventTarget();
+ Object.defineProperties(nullContextCanvas, {
+ width: { value: 0, writable: true },
+ height: { value: 0, writable: true },
+ getContext: { value: () => null },
+ });
+ createWorkerMessageHandler((message) => nullContextAcknowledgements.push(message))({
+ type: "init",
+ canvas: nullContextCanvas,
+ theme: TEST_THEME,
+ });
+ expect(nullContextAcknowledgements).toEqual([
+ {
+ type: "fatal",
+ reason: "Sheetwrite: Paint worker could not acquire a 2D context",
+ },
+ ]);
+
+ const contextLossAcknowledgements: unknown[] = [];
+ const contextLossCanvas = new EventTarget();
+ Object.defineProperties(contextLossCanvas, {
+ width: { value: 0, writable: true },
+ height: { value: 0, writable: true },
+ getContext: { value: () => ({}) },
+ });
+ createWorkerMessageHandler((message) => contextLossAcknowledgements.push(message))({
+ type: "init",
+ canvas: contextLossCanvas,
+ theme: TEST_THEME,
+ });
+ contextLossCanvas.dispatchEvent(new Event("contextlost"));
+ contextLossCanvas.dispatchEvent(new Event("contextlost"));
+ contextLossCanvas.dispatchEvent(new Event("contextrestored"));
+ expect(contextLossAcknowledgements).toEqual([
+ { type: "ready" },
+ { type: "fatal", reason: "Sheetwrite: Paint worker lost its 2D context" },
+ ]);
+ });
+
it("round-trips sender lifecycle payloads through the worker handler before acknowledging", () => {
+ jest.useFakeTimers();
const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker");
const transferDescriptor = Object.getOwnPropertyDescriptor(
HTMLCanvasElement.prototype,
"transferControlToOffscreen",
);
- const offscreen = {
- width: 0,
- height: 0,
- getContext: () => ({}),
- } as unknown as OffscreenCanvas;
+ const offscreen = new EventTarget();
+ Object.defineProperties(offscreen, {
+ width: { value: 0, writable: true },
+ height: { value: 0, writable: true },
+ getContext: { value: () => ({}) },
+ });
Object.defineProperty(globalThis, "Worker", {
configurable: true,
value: RecordingWorker,
@@ -456,7 +516,8 @@ describe("WorkerRenderer", () => {
value: () => offscreen,
});
- const renderer = new WorkerRenderer("/worker.js");
+ const failures: unknown[] = [];
+ const renderer = new WorkerRenderer("/worker.js", {}, (error) => failures.push(error));
try {
const host = document.createElement("div");
const layout: RenderLayout = {
@@ -465,22 +526,7 @@ describe("WorkerRenderer", () => {
headerHeight: 24,
totalRows: 0,
};
- const theme: Theme = {
- font: "12px sans-serif",
- bg: "#fff",
- fg: "#111",
- gridLine: "#ddd",
- headerBg: "#eee",
- headerFg: "#222",
- selection: "#def",
- selectionBorder: "#08f",
- rowHeight: 20,
- headerHeight: 24,
- rowHeaderWidth: 40,
- searchMatch: "#ff0",
- searchActiveMatch: "#fa0",
- highlight: "#cfc",
- };
+ const theme = TEST_THEME;
latestConstructedWorker = null;
renderer.mount(host, theme);
@@ -511,20 +557,80 @@ describe("WorkerRenderer", () => {
for (const { message } of worker.messages.slice(0, -1)) {
handleWorkerMessage(message);
}
- expect(acknowledgements).toEqual([]);
+ expect(acknowledgements).toEqual([{ type: "ready" }]);
+ jest.advanceTimersByTime(30_000);
+ expect(failures).toHaveLength(0);
expect(canvas.dataset.workerFrame).toBe("0");
handleWorkerMessage(paintPayload);
- expect(acknowledgements).toEqual([{ type: "painted" }]);
+ expect(acknowledgements).toEqual([{ type: "ready" }, { type: "painted" }]);
expect(canvas.dataset.workerFrame).toBe("1");
renderer.destroy();
handleWorkerMessage(worker.messages.at(-1)?.message);
- expect(acknowledgements).toEqual([{ type: "painted" }]);
+ expect(acknowledgements).toEqual([{ type: "ready" }, { type: "painted" }]);
expect(worker.terminations).toBe(1);
expect(host.querySelector("canvas")).toBeNull();
} finally {
renderer.destroy();
+ jest.useRealTimers();
+ if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor);
+ else Reflect.deleteProperty(globalThis, "Worker");
+ if (transferDescriptor) {
+ Object.defineProperty(
+ HTMLCanvasElement.prototype,
+ "transferControlToOffscreen",
+ transferDescriptor,
+ );
+ } else {
+ Reflect.deleteProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen");
+ }
+ }
+ });
+
+ it("guards duplicate fatal signals and cancels readiness failure on destroy", () => {
+ jest.useFakeTimers();
+ const workerDescriptor = Object.getOwnPropertyDescriptor(globalThis, "Worker");
+ const transferDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLCanvasElement.prototype,
+ "transferControlToOffscreen",
+ );
+ Object.defineProperty(globalThis, "Worker", {
+ configurable: true,
+ value: RecordingWorker,
+ });
+ Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", {
+ configurable: true,
+ value: () => new EventTarget(),
+ });
+
+ const failures: unknown[] = [];
+ const renderer = new WorkerRenderer("/worker.js", {}, (error) => failures.push(error));
+ try {
+ latestConstructedWorker = null;
+ renderer.mount(document.createElement("div"), TEST_THEME);
+ const worker = latestWorker();
+ worker.emitMessage({ type: "fatal", reason: "context unavailable" });
+ worker.emitMessage({ type: "fatal", reason: "duplicate fatal" });
+ worker.dispatchEvent(new Event("error", { cancelable: true }));
+ jest.advanceTimersByTime(30_000);
+ expect(failures).toHaveLength(1);
+ expect(worker.terminations).toBe(1);
+
+ const destroyedFailures: unknown[] = [];
+ const destroyed = new WorkerRenderer("/worker.js", {}, (error) =>
+ destroyedFailures.push(error),
+ );
+ latestConstructedWorker = null;
+ destroyed.mount(document.createElement("div"), TEST_THEME);
+ const destroyedWorker = latestWorker();
+ destroyed.destroy();
+ jest.advanceTimersByTime(30_000);
+ expect(destroyedFailures).toHaveLength(0);
+ expect(destroyedWorker.terminations).toBe(1);
+ } finally {
+ renderer.destroy();
+ jest.useRealTimers();
if (workerDescriptor) Object.defineProperty(globalThis, "Worker", workerDescriptor);
else Reflect.deleteProperty(globalThis, "Worker");
if (transferDescriptor) {
diff --git a/scripts/public-api-baseline.json b/scripts/public-api-baseline.json
index f1e946da..7220914f 100644
--- a/scripts/public-api-baseline.json
+++ b/scripts/public-api-baseline.json
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"manifestFormatVersion": 2,
- "sha256": "9cc3fe343edebb80e5eb431b802655092d5ccd02d94ab4dfab76127c312d6192"
+ "sha256": "82b1c79b103c9a2ac16749c3583014ee202785a846e1b3e62769328eeb6c0ceb"
}