diff --git a/src/core/lib/ImageWorker.test.ts b/src/core/lib/ImageWorker.test.ts index 73aed5c..263a3b2 100644 --- a/src/core/lib/ImageWorker.test.ts +++ b/src/core/lib/ImageWorker.test.ts @@ -34,6 +34,10 @@ let createObjectURL: ReturnType; let revokeObjectURL: ReturnType; beforeEach(() => { + // The constructor schedules a warmup `setTimeout`. Fake timers keep that + // spawn under each test's control — otherwise it fires after the test ends, + // once `unstubAllGlobals` has removed `self`/`Worker`, and throws. + vi.useFakeTimers(); FakeWorker.reset(); createObjectURL = vi.fn(() => 'blob:fake-url'); revokeObjectURL = vi.fn(); @@ -51,6 +55,9 @@ beforeEach(() => { }); afterEach(() => { + // Discards any warmup timer the test left pending, before the globals it + // depends on are torn down. + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -58,12 +65,29 @@ const load = (mgr: ImageWorkerManager) => void mgr.getImage('img.png', null, null, null, null, null); describe('ImageWorkerManager pool spawning', () => { - it('does not spawn any workers at construction', () => { + it('does not spawn any workers synchronously at construction', () => { new ImageWorkerManager(3, support); - // Spawning is lazy — nothing happens until the first image request. + // Spawning is deferred a macrotask so it stays off the synchronous + // renderer-construction path. expect(FakeWorker.instances.length).toBe(0); }); + it('spawns the whole pool on the timer scheduled at construction', () => { + new ImageWorkerManager(3, support); + expect(FakeWorker.instances.length).toBe(0); + vi.runAllTimers(); + expect(FakeWorker.instances.length).toBe(3); + }); + + it('does not respawn when the warmup timer fires after a lazy spawn', () => { + const mgr = new ImageWorkerManager(3, support); + load(mgr); // request beats the timer — spawns the pool lazily + expect(FakeWorker.instances.length).toBe(3); + vi.runAllTimers(); + // Timer must be a no-op now, not a second pool. + expect(FakeWorker.instances.length).toBe(3); + }); + it('spawns the whole pool at once on the first image request', () => { const mgr = new ImageWorkerManager(3, support); load(mgr); diff --git a/src/core/lib/ImageWorker.ts b/src/core/lib/ImageWorker.ts index a50ed4d..628eb16 100644 --- a/src/core/lib/ImageWorker.ts +++ b/src/core/lib/ImageWorker.ts @@ -198,11 +198,32 @@ export class ImageWorkerManager { ) { this.maxWorkers = numImageWorkers; this.createImageBitmapSupport = createImageBitmapSupport; + // Warm the pool on the next macrotask instead of waiting for the first + // image request. Spawning costs main-thread time (blob serialization + + // one Worker construction per slot); deferring it by a task keeps it off + // the synchronous renderer-construction path, while still having the pool + // ready before textures start arriving. `getImage` keeps its lazy spawn + // for a request that beats this timer. + setTimeout(() => { + // Not a hot path (fires once, at boot). The guard matters because this + // runs detached from any caller: `new Worker(blob:)` can throw outright + // where CSP forbids blob workers, and an uncaught throw here would take + // out whatever task the timer landed in. Swallowing it leaves the pool + // empty, so `getImage` retries the spawn inside its own try/catch and + // surfaces the failure as a rejected image promise — the pre-warmup + // behavior. + try { + this.spawnWorkers(); + } catch (e) { + /* empty */ + } + }, 0); } /** * Build the shared worker source once and spawn the full pool in a single - * burst. Called lazily on the first image request. No-op once spawned. + * burst. Scheduled from the constructor via `setTimeout`; also called + * lazily from `getImage` if a request arrives first. No-op once spawned. */ private spawnWorkers(): void { if (this.workers.length > 0) { @@ -333,8 +354,8 @@ export class ImageWorkerManager { try { let nextWorkerIndex = this.getNextWorkerIndex(); if (nextWorkerIndex === -1) { - // Pool not spawned yet — spin up all workers at once on the first - // image request, off the boot/first-render critical path. + // Request beat the constructor's scheduled warmup — spin up the + // whole pool now rather than making this image wait a task. this.spawnWorkers(); nextWorkerIndex = this.getNextWorkerIndex(); }