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;
    }
  }
}