File size: 4,688 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | /**
* 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<T> {
/**
* 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<T> {
private readonly committed = new Map<string, T>();
private readonly pending = new Map<string, T>();
private cancelHandle: (() => void) | null = null;
private readonly equals: (a: T | undefined, b: T | undefined) => boolean;
constructor(private readonly deps: DeferredCommitDeps<T>) {
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;
}
}
}
|