@perspective-dev/viewer-charts 4.5.0 → 4.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE.md +193 -0
  2. package/dist/cdn/perspective-viewer-charts.js +2 -2
  3. package/dist/cdn/perspective-viewer-charts.js.map +3 -3
  4. package/dist/esm/axis/bar-axis.d.ts +9 -1
  5. package/dist/esm/axis/categorical-axis.d.ts +0 -2
  6. package/dist/esm/charts/cartesian/cartesian.d.ts +26 -0
  7. package/dist/esm/charts/common/category-axis-resolver.d.ts +43 -1
  8. package/dist/esm/charts/common/expand-domain.d.ts +20 -0
  9. package/dist/esm/charts/common/tree-chart.d.ts +7 -0
  10. package/dist/esm/charts/common/tree-chrome.d.ts +23 -1
  11. package/dist/esm/charts/common/tree-interact.d.ts +46 -0
  12. package/dist/esm/charts/series/glyphs/draw-lines.d.ts +11 -4
  13. package/dist/esm/charts/series/series-build.d.ts +38 -2
  14. package/dist/esm/charts/series/series-render.d.ts +1 -4
  15. package/dist/esm/charts/series/series-type.d.ts +19 -17
  16. package/dist/esm/charts/series/series.d.ts +16 -0
  17. package/dist/esm/charts/sunburst/sunburst-interact.d.ts +1 -1
  18. package/dist/esm/charts/treemap/treemap-interact.d.ts +1 -6
  19. package/dist/esm/config.d.ts +19 -0
  20. package/dist/esm/interaction/host-sink-message.d.ts +10 -28
  21. package/dist/esm/interaction/raw-event-forwarder.d.ts +6 -7
  22. package/dist/esm/interaction/zoom-controller.d.ts +31 -20
  23. package/dist/esm/interaction/zoom-router.d.ts +3 -26
  24. package/dist/esm/perspective-viewer-charts.js +2 -2
  25. package/dist/esm/perspective-viewer-charts.js.map +3 -3
  26. package/dist/esm/plugin/plugin.d.ts +15 -3
  27. package/dist/esm/render/scheduler.d.ts +23 -0
  28. package/dist/esm/theme/palette.d.ts +0 -5
  29. package/dist/esm/transport/protocol.d.ts +2 -7
  30. package/dist/esm/webgl/context-manager.d.ts +100 -117
  31. package/dist/esm/webgl/context-pool.d.ts +40 -0
  32. package/dist/esm/webgl/gl-context.d.ts +54 -0
  33. package/dist/esm/worker/renderer.worker.d.ts +2 -4
  34. package/package.json +45 -45
  35. package/src/ts/axis/bar-axis.ts +74 -45
  36. package/src/ts/axis/categorical-axis.ts +0 -2
  37. package/src/ts/charts/candlestick/candlestick-render.ts +10 -7
  38. package/src/ts/charts/candlestick/candlestick.ts +10 -29
  39. package/src/ts/charts/candlestick/glyphs/draw-candlesticks.ts +36 -2
  40. package/src/ts/charts/candlestick/glyphs/draw-ohlc.ts +36 -2
  41. package/src/ts/charts/cartesian/cartesian-build.ts +143 -9
  42. package/src/ts/charts/cartesian/cartesian-render.ts +205 -30
  43. package/src/ts/charts/cartesian/cartesian.ts +43 -4
  44. package/src/ts/charts/cartesian/glyphs/density.ts +36 -41
  45. package/src/ts/charts/cartesian/glyphs/lines.ts +13 -15
  46. package/src/ts/charts/cartesian/glyphs/points.ts +12 -17
  47. package/src/ts/charts/chart-base.ts +20 -6
  48. package/src/ts/charts/chart.ts +1 -1
  49. package/src/ts/charts/common/category-axis-resolver.ts +135 -1
  50. package/src/ts/charts/common/expand-domain.ts +40 -0
  51. package/src/ts/charts/common/tree-chart.ts +16 -0
  52. package/src/ts/charts/common/tree-chrome.ts +86 -1
  53. package/src/ts/charts/common/tree-interact.ts +209 -0
  54. package/src/ts/charts/heatmap/heatmap-render.ts +9 -11
  55. package/src/ts/charts/series/glyphs/draw-areas.ts +30 -1
  56. package/src/ts/charts/series/glyphs/draw-lines.ts +151 -76
  57. package/src/ts/charts/series/series-build.ts +394 -21
  58. package/src/ts/charts/series/series-render.ts +159 -38
  59. package/src/ts/charts/series/series-type.ts +37 -17
  60. package/src/ts/charts/series/series.ts +63 -68
  61. package/src/ts/charts/sunburst/sunburst-interact.ts +18 -162
  62. package/src/ts/charts/sunburst/sunburst-render.ts +24 -89
  63. package/src/ts/charts/sunburst/sunburst.ts +1 -15
  64. package/src/ts/charts/treemap/treemap-interact.ts +22 -189
  65. package/src/ts/charts/treemap/treemap-render.ts +19 -46
  66. package/src/ts/charts/treemap/treemap.ts +1 -16
  67. package/src/ts/config.ts +21 -1
  68. package/src/ts/interaction/host-sink-message.ts +33 -22
  69. package/src/ts/interaction/raw-event-forwarder.ts +10 -12
  70. package/src/ts/interaction/zoom-controller.ts +120 -83
  71. package/src/ts/interaction/zoom-router.ts +3 -126
  72. package/src/ts/map/tile-layer.ts +13 -13
  73. package/src/ts/plugin/plugin.ts +176 -196
  74. package/src/ts/render/scheduler.ts +125 -42
  75. package/src/ts/shaders/line-uniform.frag.glsl +2 -1
  76. package/src/ts/shaders/line-uniform.vert.glsl +19 -0
  77. package/src/ts/theme/palette.ts +1 -4
  78. package/src/ts/transport/protocol.ts +3 -8
  79. package/src/ts/webgl/context-manager.ts +195 -189
  80. package/src/ts/webgl/context-pool.ts +78 -0
  81. package/src/ts/webgl/gl-context.ts +191 -0
  82. package/src/ts/worker/dispatch.ts +0 -1
  83. package/src/ts/worker/renderer.worker.ts +72 -65
  84. package/src/ts/worker/session-host.ts +29 -0
@@ -0,0 +1,191 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ import { ShaderRegistry } from "./shader-registry";
14
+ import { SHADER_MANIFEST } from "./shader-manifest";
15
+
16
+ export type WebGLCanvas = HTMLCanvasElement | OffscreenCanvas;
17
+
18
+ /**
19
+ * The unit the browser actually counts against its per-agent WebGL
20
+ * context cap (~16 in Chromium): one canvas + one GL context + the
21
+ * shader programs compiled against it.
22
+ *
23
+ * Factored out of {@link WebGLContextManager} so that *many* managers
24
+ * (one per chart) can share *one* GL context. The browser limit is on
25
+ * contexts, not on the buffers/programs living inside one — a single
26
+ * context can host an arbitrary number of independent
27
+ * `WebGLBuffer`s/programs (bounded only by memory). So N charts can
28
+ * render through K ≪ N contexts as long as each keeps its own
29
+ * `BufferPool` (buffers are keyed by name; two charts would otherwise
30
+ * collide) and renders are serialized per context (the shared drawing
31
+ * buffer can hold one chart's frame at a time). See
32
+ * [context-pool.ts](./context-pool.ts) and the scheduler's per-backend
33
+ * grouping in [../render/scheduler.ts](../render/scheduler.ts).
34
+ *
35
+ * Shaders are shared per context: programs are immutable GLSL once
36
+ * compiled (uniforms are set per draw), so co-tenant charts reuse them
37
+ * and only pay the compile cost once per context.
38
+ */
39
+ let NEXT_CONTEXT_ID = 1;
40
+
41
+ export class GLContext {
42
+ readonly id: number;
43
+ readonly canvas: WebGLCanvas;
44
+ readonly gl: WebGL2RenderingContext | WebGLRenderingContext;
45
+ readonly isWebGL2: boolean;
46
+ shaders: ShaderRegistry;
47
+ private _maxVertexAttribs: number;
48
+ private _angle: ANGLE_instanced_arrays | null = null;
49
+ private _precompile: boolean;
50
+ private _destroyed = false;
51
+
52
+ /**
53
+ * Per-tenant hooks fired after a `webglcontextrestored`. Each
54
+ * {@link WebGLContextManager} on this context registers one that
55
+ * rebuilds its `BufferPool` (the shaders are rebuilt here, once, for
56
+ * all tenants). On an intentional {@link destroy} these are dropped
57
+ * without firing.
58
+ */
59
+ private _onRestore = new Set<() => void>();
60
+
61
+ constructor(canvas: WebGLCanvas, options: { precompile?: boolean } = {}) {
62
+ this.id = NEXT_CONTEXT_ID++;
63
+ this.canvas = canvas;
64
+ this._precompile = options.precompile ?? false;
65
+ const gl2 = canvas.getContext("webgl2", {
66
+ antialias: true,
67
+ alpha: true,
68
+ premultipliedAlpha: false,
69
+ });
70
+
71
+ if (gl2) {
72
+ this.gl = gl2 as WebGL2RenderingContext;
73
+ this.isWebGL2 = true;
74
+ } else {
75
+ const gl1 = canvas.getContext("webgl", {
76
+ antialias: true,
77
+ alpha: true,
78
+ premultipliedAlpha: false,
79
+ });
80
+
81
+ if (!gl1) {
82
+ throw new Error("WebGL is not supported");
83
+ }
84
+
85
+ this.gl = gl1 as WebGLRenderingContext;
86
+ this.isWebGL2 = false;
87
+ }
88
+
89
+ this.shaders = new ShaderRegistry(this.gl);
90
+ if (this._precompile) {
91
+ this.shaders.precompile(SHADER_MANIFEST);
92
+ }
93
+
94
+ this._maxVertexAttribs = this.gl.getParameter(
95
+ this.gl.MAX_VERTEX_ATTRIBS,
96
+ ) as number;
97
+ if (!this.isWebGL2) {
98
+ this._angle = this.gl.getExtension("ANGLE_instanced_arrays");
99
+ }
100
+
101
+ // Both `HTMLCanvasElement` and `OffscreenCanvas` dispatch
102
+ // `webglcontextlost` / `webglcontextrestored` on the canvas.
103
+ (canvas as EventTarget).addEventListener(
104
+ "webglcontextlost",
105
+ (e: Event) => {
106
+ // Don't reserve the slot for a restore once we've
107
+ // intentionally torn the context down — `destroy()`
108
+ // calls `loseContext()`, which fires this event.
109
+ if (this._destroyed) {
110
+ return;
111
+ }
112
+
113
+ e.preventDefault();
114
+ },
115
+ );
116
+
117
+ (canvas as EventTarget).addEventListener("webglcontextrestored", () => {
118
+ // Rebuild the shared program cache once for the whole
119
+ // context, then let each tenant rebuild its own buffers.
120
+ this.shaders.releaseAll();
121
+ this.shaders = new ShaderRegistry(this.gl);
122
+ if (this._precompile) {
123
+ this.shaders.precompile(SHADER_MANIFEST);
124
+ }
125
+
126
+ for (const cb of this._onRestore) {
127
+ cb();
128
+ }
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Return every vertex-attribute slot to its default state: disabled,
134
+ * with a zero instance divisor. Vertex-array state (enable flags,
135
+ * pointers, divisors) is global to a context's default VAO — there
136
+ * are no per-chart VAOs in this codebase — so when many charts share
137
+ * one pooled context, a slot a prior chart enabled stays enabled for
138
+ * the next chart, now pointing at a buffer the prior chart's
139
+ * teardown deleted. Its next `drawArraysInstanced` then fails GL
140
+ * validation: "no buffer is bound to enabled attribute", silently
141
+ * dropping the draw.
142
+ *
143
+ * Glyphs already (re)enable + bind + set the divisor for every slot
144
+ * they use on each frame, so clearing to the default here is a safe
145
+ * baseline — it only removes the *leftover* enables a sibling chart
146
+ * left behind. Called from {@link WebGLContextManager.beginFrame} in
147
+ * shared (pooled) mode only; the 1:1 default never shares a context
148
+ * and so keeps its original, reset-free path.
149
+ */
150
+ resetVertexArrayState(): void {
151
+ const gl = this.gl;
152
+ for (let i = 0; i < this._maxVertexAttribs; i++) {
153
+ gl.disableVertexAttribArray(i);
154
+ if (this.isWebGL2) {
155
+ (gl as WebGL2RenderingContext).vertexAttribDivisor(i, 0);
156
+ } else {
157
+ this._angle?.vertexAttribDivisorANGLE(i, 0);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Register a tenant's post-restore rebuild hook. Returns a disposer.
164
+ */
165
+ onRestore(cb: () => void): () => void {
166
+ this._onRestore.add(cb);
167
+ return () => this._onRestore.delete(cb);
168
+ }
169
+
170
+ /**
171
+ * True once lost — either our own {@link destroy} or a browser
172
+ * eviction of the oldest context past the per-agent cap.
173
+ */
174
+ isContextLost(): boolean {
175
+ return this._destroyed || this.gl.isContextLost();
176
+ }
177
+
178
+ get destroyed(): boolean {
179
+ return this._destroyed;
180
+ }
181
+
182
+ destroy(): void {
183
+ this._destroyed = true;
184
+ this._onRestore.clear();
185
+ this.shaders.releaseAll();
186
+ const ext = this.gl.getExtension("WEBGL_lose_context");
187
+ if (ext) {
188
+ ext.loseContext();
189
+ }
190
+ }
191
+ }
@@ -41,7 +41,6 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void {
41
41
  r.redraw();
42
42
  break;
43
43
  case "resize":
44
- console.log("resize");
45
44
  r.resize(msg.cssWidth, msg.cssHeight, msg.dpr);
46
45
  r.redraw();
47
46
  break;
@@ -16,6 +16,8 @@ import type { Client, Table, View } from "@perspective-dev/client";
16
16
 
17
17
  import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspective-viewer.js";
18
18
  import { WebGLContextManager } from "../webgl/context-manager";
19
+ import { ContextPool } from "../webgl/context-pool";
20
+ import { RENDER_CONTEXT_POOL_SIZE } from "../config";
19
21
  import { ChartImplementation } from "../charts/chart";
20
22
  import { ZoomController } from "../interaction/zoom-controller";
21
23
  import {
@@ -37,7 +39,7 @@ import { viewToColumnDataMap } from "../data/view-reader";
37
39
  import { loadFontDeduped } from "./font-loader";
38
40
  import { dispatch } from "./dispatch";
39
41
  import { installSessionHost } from "./session-host";
40
- import { deferIfDraining } from "../render/scheduler";
42
+ import { deferIfDraining, unregister } from "../render/scheduler";
41
43
 
42
44
  /**
43
45
  * Sentinel thrown inside the `with_typed_arrays` callback when a newer
@@ -76,6 +78,25 @@ async function resolveChartImpl(
76
78
  return await factory();
77
79
  }
78
80
 
81
+ /**
82
+ * Renderer-scope shared context pool for pooled blit mode. Lazily
83
+ * created on first use so non-blit / non-pooled scopes never allocate
84
+ * one. One per renderer scope (the worker, or the main thread in
85
+ * in-process mode) — `WorkerRenderer`s in the same scope share its K
86
+ * contexts.
87
+ */
88
+ let CONTEXT_POOL: ContextPool | null = null;
89
+
90
+ function getContextPool(precompile: boolean): ContextPool {
91
+ if (!CONTEXT_POOL) {
92
+ CONTEXT_POOL = new ContextPool(RENDER_CONTEXT_POOL_SIZE, {
93
+ precompile,
94
+ });
95
+ }
96
+
97
+ return CONTEXT_POOL;
98
+ }
99
+
79
100
  export class WorkerRenderer {
80
101
  chartImpl: ChartImplementation;
81
102
  glManager: WebGLContextManager;
@@ -131,20 +152,35 @@ export class WorkerRenderer {
131
152
 
132
153
  this.chartImpl = new ImplClass();
133
154
 
134
- // Direct mode hands us the host's transferred `.webgl-canvas`.
135
- // Blit mode omits it — the renderer owns its own offscreen
136
- // surface and posts each completed frame back as an
137
- // `ImageBitmap` via the `endFrame` callback wired below.
138
- const glCanvas =
139
- msg.glCanvas ??
140
- new OffscreenCanvas(
141
- Math.max(1, Math.round(msg.cssWidth * msg.dpr)),
142
- Math.max(1, Math.round(msg.cssHeight * msg.dpr)),
155
+ // Three surfaces, by mode:
156
+ // - direct: the host's transferred `.webgl-canvas` (1:1 with a
157
+ // context, permanently).
158
+ // - pooled blit: borrow one of K shared contexts so live-
159
+ // context count stays bounded regardless of chart count.
160
+ // - unpooled blit: a private offscreen surface per chart.
161
+ // Blit modes omit `msg.glCanvas`; the renderer ships each frame
162
+ // as an `ImageBitmap` via the `endFrame` callback wired below.
163
+ const pooled =
164
+ msg.renderMode === "blit" &&
165
+ !msg.glCanvas &&
166
+ RENDER_CONTEXT_POOL_SIZE > 0;
167
+
168
+ if (pooled) {
169
+ this.glManager = new WebGLContextManager(
170
+ getContextPool(msg.precompileShaders ?? false).acquire(),
143
171
  );
172
+ } else {
173
+ const glCanvas =
174
+ msg.glCanvas ??
175
+ new OffscreenCanvas(
176
+ Math.max(1, Math.round(msg.cssWidth * msg.dpr)),
177
+ Math.max(1, Math.round(msg.cssHeight * msg.dpr)),
178
+ );
144
179
 
145
- this.glManager = new WebGLContextManager(glCanvas, {
146
- precompile: msg.precompileShaders ?? false,
147
- });
180
+ this.glManager = new WebGLContextManager(glCanvas, {
181
+ precompile: msg.precompileShaders ?? false,
182
+ });
183
+ }
148
184
 
149
185
  if (msg.renderMode === "blit") {
150
186
  this.glManager.setFrameCallback((bitmap) => {
@@ -177,41 +213,11 @@ export class WorkerRenderer {
177
213
  this.chartImpl.setView?.(view);
178
214
  this.glManager.bufferPool.maxCapacity = msg.bufferMaxCapacity;
179
215
  this.glManager.resize(msg.cssWidth, msg.cssHeight, msg.dpr);
180
- const hostSink = new MessageHostSink((envelope) => {
181
- switch (envelope.kind) {
182
- case "pin":
183
- this.post({
184
- kind: "pinTooltip",
185
- lines: envelope.payload.lines,
186
- pos: envelope.payload.pos,
187
- bounds: envelope.payload.bounds,
188
- });
189
- break;
190
- case "dismiss":
191
- this.post({ kind: "dismissTooltip" });
192
- break;
193
- case "setCursor":
194
- this.post({ kind: "setCursor", cursor: envelope.cursor });
195
- break;
196
- case "userClick":
197
- this.post({
198
- kind: "userClick",
199
- detail: envelope.payload as any,
200
- });
201
- break;
202
- case "userSelect":
203
- this.post({
204
- kind: "userSelect",
205
- selected: envelope.payload.selected,
206
- row: envelope.payload.row,
207
- column_names: envelope.payload.column_names,
208
- insertConfig: envelope.payload.insertConfig as any,
209
- });
210
- break;
211
- }
212
- });
213
-
214
- this.chartImpl.attachTooltip?.(hostSink);
216
+ // `MessageHostSink` emits `WorkerMsg`s directly — no translation
217
+ // needed here.
218
+ this.chartImpl.attachTooltip?.(
219
+ new MessageHostSink((msg) => this.post(msg)),
220
+ );
215
221
  }
216
222
 
217
223
  setViewByName(name: string): void {
@@ -309,13 +315,9 @@ export class WorkerRenderer {
309
315
  }
310
316
  }
311
317
  } catch (err) {
312
- // Any unexpected throw — proxy hiccup, chart-impl mutation
313
- // failure, RAF chain rejection — must not leak past the
314
- // outer fire-and-forget caller (`dispatch` does not await
315
- // this method). Surface to the worker console; the host's
316
- // pending promise still gets resolved via the `finally`
317
- // ack below so `draw()` can't deadlock on a renderer error.
318
- console.error("loadAndRender failed", err);
318
+ if ((err + "").indexOf("View not found") === -1) {
319
+ console.error("loadAndRender failed", err);
320
+ }
319
321
  } finally {
320
322
  this.post({ kind: "loadAndRenderAck", msgId: msg.msgId });
321
323
  }
@@ -417,10 +419,8 @@ export class WorkerRenderer {
417
419
 
418
420
  /**
419
421
  * Hit-test the cursor against the chart's facet grid (in faceted
420
- * mode) or its current layout (single-plot). Mirrors the resolver
421
- * `_setupZoomRouter` builds on the host for in-process mode — the
422
- * worker owns the facet grid and controllers, so the resolution
423
- * runs here.
422
+ * mode) or its current layout (single-plot). The worker owns the
423
+ * facet grid and controllers, so the resolution runs here.
424
424
  */
425
425
  private _resolveTarget(mx: number, my: number): ZoomTarget | null {
426
426
  const chart = this.chartImpl as any;
@@ -564,12 +564,14 @@ export class WorkerRenderer {
564
564
  * Composite the three layers into a single PNG `Blob`.
565
565
  */
566
566
  async snapshotPng(): Promise<Blob> {
567
- // Snapshot bypasses the scheduler's drain, so it must
568
- // mirror Phase 1's "apply pending resize before paint"
569
- // step itself — otherwise a snapshot taken after a resize
570
- // message but before the next drain would render at the
571
- // previous dimensions.
572
- this.glManager.applyPendingResize();
567
+ // Snapshot bypasses the scheduler's drain, so it must mirror
568
+ // the per-frame prep itself — apply any pending resize, and in
569
+ // pooled blit mode size the shared canvas to this chart and
570
+ // reset shared GL state — otherwise a snapshot taken after a
571
+ // resize message but before the next drain (or after a co-tenant
572
+ // chart left the shared canvas at its own size) would render at
573
+ // the wrong dimensions.
574
+ this.glManager.beginFrame();
573
575
  this.chartImpl._fullRender(this.glManager);
574
576
  const gl = this.glManager.gl;
575
577
  const glCanvas = gl.canvas as OffscreenCanvas;
@@ -610,6 +612,11 @@ export class WorkerRenderer {
610
612
  }
611
613
 
612
614
  destroy(): void {
615
+ // Drop any frame this manager still has queued in the
616
+ // module-level scheduler *before* losing its GL context, so a
617
+ // next-RAF `drain()` can't paint/present against a dead
618
+ // context (the "scheduler: present failed" path).
619
+ unregister(this.glManager);
613
620
  this.chartImpl.destroy();
614
621
  this.glManager.destroy();
615
622
  }
@@ -21,6 +21,20 @@ import { dispatch } from "./dispatch";
21
21
 
22
22
  const RENDERERS = new Map<number, WorkerRenderer>();
23
23
 
24
+ /**
25
+ * Sessions whose `destroy` arrived before their async `bootstrap`
26
+ * resolved. Bootstrap does multiple awaits (wasm `initSync`, font
27
+ * loads, `open_table`, lazy chart-impl import); a host that connects
28
+ * then immediately disconnects a viewer can post `init` and `destroy`
29
+ * back-to-back, with `destroy` landing while the renderer is still
30
+ * being built. The plain `RENDERERS.get(sessionId)` lookup in the
31
+ * destroy branch would miss it and the bootstrap would then register a
32
+ * renderer (and its WebGL context) that nothing ever tears down —
33
+ * leaking one context per raced toggle until the browser evicts the
34
+ * oldest. We tombstone the id here and honor it when bootstrap lands.
35
+ */
36
+ const PENDING_DESTROY = new Set<number>();
37
+
24
38
  function postSession(
25
39
  sessionId: number,
26
40
  msg: WorkerMsg,
@@ -89,9 +103,18 @@ export function installSessionHost(
89
103
 
90
104
  bootstrap(msg as InitMsg, makeSessionPort(sessionId))
91
105
  .then((r) => {
106
+ // A `destroy` raced ahead of bootstrap completing —
107
+ // honor it now instead of leaking the freshly-built
108
+ // renderer + its WebGL context.
109
+ if (PENDING_DESTROY.delete(sessionId)) {
110
+ dispatch(r, { kind: "destroy" });
111
+ return;
112
+ }
113
+
92
114
  RENDERERS.set(sessionId, r);
93
115
  })
94
116
  .catch((err) => {
117
+ PENDING_DESTROY.delete(sessionId);
95
118
  postSession(sessionId, {
96
119
  kind: "error",
97
120
  message: String(err),
@@ -105,6 +128,12 @@ export function installSessionHost(
105
128
  if (r) {
106
129
  dispatch(r, msg);
107
130
  RENDERERS.delete(sessionId);
131
+ } else {
132
+ // Renderer not registered yet — bootstrap is still in
133
+ // flight. Tombstone the id so the bootstrap `.then`
134
+ // tears it down on arrival instead of registering a
135
+ // leaked context.
136
+ PENDING_DESTROY.add(sessionId);
108
137
  }
109
138
 
110
139
  return;