/** * Two-phase heavy-layer data commit for DeckGLMap (#4558 / #4537). * * The interaction-attributed render frame must not synchronously tessellate a * heavy deck.gl layer (conflict-zone GeoJson, Supercluster cluster layers). This * gate models the *scheduling and per-layer data state* of a two-phase commit, * deliberately free of any DOM/deck.gl/WebGL dependency so it is unit-testable * under `tsx --test`: * * - Phase 1 (immediate): the render builds each heavy layer with its * PREVIOUSLY-committed data via `present(key)` — so an already-visible layer * never blanks (R2 no-flicker); a first build has no previous data and shows * empty for one frame (R3, stable id, acceptable since it was not visible). * - Phase 2 (deferred): a coalesced, teardown-guarded flush moves staged data * to committed and calls `onCommit(changedKeys)`, which the owner uses to run * a second render carrying the real heavy data — off the interaction frame. * * The owner (DeckGLMap) wires `schedule` to `yieldToMain`, `isAlive` to the * `!renderPaused && !webglLost && maplibreMap` guard, and `onCommit` to a * deferred `updateLayers`. This module owns none of that — only the state machine. */ export interface DeferredCommitDeps { /** * Schedule the deferred flush to run later (e.g. `yieldToMain`/`setTimeout(0)`). * Returns a cancel handle so a superseding stage can coalesce. */ schedule: (run: () => void) => () => void; /** True while the map can still receive a commit (not paused/destroyed/WebGL-lost). */ isAlive: () => boolean; /** Called on the deferred flush with the heavy-layer keys whose data changed. */ onCommit: (changedKeys: string[]) => void; /** Equality used to decide whether staged data differs from committed (default `Object.is`). */ equals?: (a: T | undefined, b: T | undefined) => boolean; } export class DeferredHeavyCommit { private readonly committed = new Map(); private readonly pending = new Map(); private cancelHandle: (() => void) | null = null; private readonly equals: (a: T | undefined, b: T | undefined) => boolean; constructor(private readonly deps: DeferredCommitDeps) { this.equals = deps.equals ?? ((a, b) => Object.is(a, b)); } /** * Stage a heavy layer's newly-available data. If it differs from what is * currently committed, schedule a coalesced deferred flush. A burst of stage() * calls before the flush runs collapses to a single flush. */ stage(key: string, data: T): void { this.pending.set(key, data); if (this.equals(data, this.committed.get(key))) { // No real change for this key; don't schedule on its account. If this // empties the pending set, drop any flush a prior stage() scheduled so // hasPending() doesn't report a flush that would commit nothing. this.pending.delete(key); if (this.pending.size === 0) this.clearHandle(); return; } this.scheduleFlush(); } /** Data to present for `key` on the immediate (Phase 1) render: the last committed value, or undefined on first build. */ present(key: string): T | undefined { return this.committed.get(key); } /** Whether a deferred flush is currently scheduled. */ hasPending(): boolean { return this.cancelHandle !== null; } /** Keys currently staged but not yet committed (for assertions/diagnostics). */ pendingKeys(): string[] { return [...this.pending.keys()]; } /** * Run any scheduled flush immediately. Honors the teardown guard: if the map * is no longer alive, the flush is dropped without committing (R4). */ flushNow(): void { this.clearHandle(); if (this.pending.size === 0) return; if (!this.deps.isAlive()) { // Teardown between schedule and flush — drop staged data, commit nothing. this.pending.clear(); return; } const changed: string[] = []; for (const [key, data] of this.pending) { if (!this.equals(data, this.committed.get(key))) changed.push(key); this.committed.set(key, data); } this.pending.clear(); if (changed.length > 0) this.deps.onCommit(changed); } /** Drop any scheduled flush and staged data without committing (teardown/destroy). */ cancel(): void { this.clearHandle(); this.pending.clear(); } private scheduleFlush(): void { // Coalesce: cancel a prior scheduled flush and reschedule one. this.clearHandle(); this.cancelHandle = this.deps.schedule(() => this.flushNow()); } private clearHandle(): void { if (this.cancelHandle) { this.cancelHandle(); this.cancelHandle = null; } } }