From 3e55b069cc799aa33e31e2b2a20866c5679f15f3 Mon Sep 17 00:00:00 2001 From: Youssef Siam Date: Sat, 16 May 2026 16:17:04 +0300 Subject: [PATCH 1/3] fix(pwa): add standard mobile-web-app-capable meta tag Chrome 102+ deprecates apple-mobile-web-app-capable and emits a console warning on every page load asking for the unprefixed mobile-web-app-capable to be included. Keep the Apple-prefixed tag for iOS Home Screen and add the standard one alongside it. Co-Authored-By: Claude Opus 4.7 (1M context) --- index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/index.html b/index.html index d2fc4ffa..262467d4 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ + From 78f6620d79599fdd1e510f40a81087e23c259a32 Mon Sep 17 00:00:00 2001 From: Youssef Siam Date: Sat, 16 May 2026 16:17:10 +0300 Subject: [PATCH 2/3] fix(canvas): harden LocalGraphView against WebGL context exhaustion LocalGraphView blew up the route-level Error Boundary with TypeError: Cannot read properties of null (reading 'blendFunc') at new Sigma (...) whenever Sigma's constructor called gl.blendFunc(...) on a null WebGL context. The root cause is the browser's per-tab WebGL context cap (~16 on Chrome, ~8 on Safari): sigma.kill() detaches the canvas but the underlying GL context lingers until GC, so rapid re-centers of the local graph (combined with the always-mounted main SigmaCanvas) eventually exhaust the pool. The next `new Sigma` then receives a canvas whose getContext returns null and crashes on the first GL state call. Two changes: 1. Introduce a releaseSigma() helper that calls sigma.kill() and then explicitly invokes WEBGL_lose_context.loseContext() on every canvas inside the container. That frees the GL slot immediately rather than waiting on GC. Use it from both the in-effect "previous sigma" cleanup and the effect's return cleanup. 2. Wrap `new Sigma(...)` in try/catch. If the context can't be acquired we surface a friendly error state instead of letting the crash hit the Error Boundary and blank the whole page. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/canvas/LocalGraphView.tsx | 136 ++++++++++++++--------- 1 file changed, 84 insertions(+), 52 deletions(-) diff --git a/src/components/canvas/LocalGraphView.tsx b/src/components/canvas/LocalGraphView.tsx index df8591b4..075517f2 100644 --- a/src/components/canvas/LocalGraphView.tsx +++ b/src/components/canvas/LocalGraphView.tsx @@ -12,6 +12,27 @@ const SIZE_CENTER = 18; const SIZE_DEPTH_1 = 11; const SIZE_DEPTH_2 = 7; +/** + * Kill a Sigma instance and proactively release its WebGL contexts. + * + * `sigma.kill()` detaches its canvases but the browser may hold the underlying + * WebGL contexts until GC. Browsers cap concurrent contexts per tab (~16 on + * Chrome, ~8 on Safari), and rapid re-centers of the local graph can exhaust + * the pool — the next `new Sigma` then gets a canvas whose `getContext` + * returns null and crashes on its first GL call (e.g. `gl.blendFunc`). + * `WEBGL_lose_context.loseContext()` frees the slot immediately. + */ +function releaseSigma(sigma: Sigma, container: HTMLElement) { + const canvases = Array.from(container.querySelectorAll('canvas')); + sigma.kill(); + for (const canvas of canvases) { + const gl = + (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ?? + (canvas.getContext('webgl') as WebGLRenderingContext | null); + gl?.getExtension('WEBGL_lose_context')?.loseContext(); + } +} + /** Brighten/dim an [r,g,b] triple by a 0..1 factor. */ function modulateRgb(rgb: [number, number, number], factor: number): string { return `rgb(${Math.round(rgb[0] * factor)},${Math.round(rgb[1] * factor)},${Math.round(rgb[2] * factor)})`; @@ -233,7 +254,7 @@ export function LocalGraphView() { if (!container || !graph || graph.atoms.length === 0) return; if (sigmaRef.current) { - sigmaRef.current.kill(); + releaseSigma(sigmaRef.current, container); sigmaRef.current = null; } @@ -329,56 +350,67 @@ export function LocalGraphView() { } neighborsRef.current = neighbors; - const sigma = new Sigma(g, container, { - // Labels are rendered by our overlay canvas (always-on with collision avoidance). - renderLabels: false, - defaultEdgeColor: '#333', - defaultNodeColor: '#555', - defaultEdgeType: 'curved', - zIndex: true, - edgeProgramClasses: { - curved: EdgeCurveProgram, - }, - minCameraRatio: 0.2, - maxCameraRatio: 4, - stagePadding: 80, - defaultDrawNodeHover: () => {}, // Hover ring/pill drawn on overlay - nodeReducer: (node, attrs) => { - const hovered = hoveredNodeRef.current; - if (!hovered) return attrs; - if (node === hovered) return { ...attrs, zIndex: 2 }; - const isNeighbor = neighborsRef.current.get(hovered)?.has(node); - if (isNeighbor) return { ...attrs, zIndex: 1 }; - // Non-neighbors fade toward gray. Sizes stay put — in a small ego-network shrinking - // most of the nodes at once reads as "the whole graph just got smaller", which is - // disorienting. Color fade is enough to direct attention. - const dim = hoverAnimRef.current; - const rgb = parseRgbColor(attrs.color as string); - const color = rgb - ? `rgb(${Math.round(rgb[0] + (60 - rgb[0]) * dim)},${Math.round(rgb[1] + (60 - rgb[1]) * dim)},${Math.round(rgb[2] + (60 - rgb[2]) * dim)})` - : attrs.color; - return { ...attrs, color }; - }, - edgeReducer: (edge, attrs) => { - const hovered = hoveredNodeRef.current; - if (!hovered) return attrs; - const src = g.source(edge); - const dst = g.target(edge); - const incident = src === hovered || dst === hovered; - const dim = hoverAnimRef.current; - if (incident) { - // Brighten incident edges via color (no size pump — same reason as nodeReducer). - return { ...attrs, zIndex: 1 }; - } - // Fade non-incident edges toward the background (color only). - const rgb = parseRgbColor(attrs.color as string); - const bg = parseRgbColor(themeRef.current.background) ?? [30, 30, 30]; - const color = rgb - ? `rgb(${Math.round(rgb[0] + (bg[0] - rgb[0]) * dim * 0.85)},${Math.round(rgb[1] + (bg[1] - rgb[1]) * dim * 0.85)},${Math.round(rgb[2] + (bg[2] - rgb[2]) * dim * 0.85)})` - : attrs.color; - return { ...attrs, color }; - }, - }); + let sigma: Sigma; + try { + sigma = new Sigma(g, container, { + // Labels are rendered by our overlay canvas (always-on with collision avoidance). + renderLabels: false, + defaultEdgeColor: '#333', + defaultNodeColor: '#555', + defaultEdgeType: 'curved', + zIndex: true, + edgeProgramClasses: { + curved: EdgeCurveProgram, + }, + minCameraRatio: 0.2, + maxCameraRatio: 4, + stagePadding: 80, + defaultDrawNodeHover: () => {}, // Hover ring/pill drawn on overlay + nodeReducer: (node, attrs) => { + const hovered = hoveredNodeRef.current; + if (!hovered) return attrs; + if (node === hovered) return { ...attrs, zIndex: 2 }; + const isNeighbor = neighborsRef.current.get(hovered)?.has(node); + if (isNeighbor) return { ...attrs, zIndex: 1 }; + // Non-neighbors fade toward gray. Sizes stay put — in a small ego-network shrinking + // most of the nodes at once reads as "the whole graph just got smaller", which is + // disorienting. Color fade is enough to direct attention. + const dim = hoverAnimRef.current; + const rgb = parseRgbColor(attrs.color as string); + const color = rgb + ? `rgb(${Math.round(rgb[0] + (60 - rgb[0]) * dim)},${Math.round(rgb[1] + (60 - rgb[1]) * dim)},${Math.round(rgb[2] + (60 - rgb[2]) * dim)})` + : attrs.color; + return { ...attrs, color }; + }, + edgeReducer: (edge, attrs) => { + const hovered = hoveredNodeRef.current; + if (!hovered) return attrs; + const src = g.source(edge); + const dst = g.target(edge); + const incident = src === hovered || dst === hovered; + const dim = hoverAnimRef.current; + if (incident) { + // Brighten incident edges via color (no size pump — same reason as nodeReducer). + return { ...attrs, zIndex: 1 }; + } + // Fade non-incident edges toward the background (color only). + const rgb = parseRgbColor(attrs.color as string); + const bg = parseRgbColor(themeRef.current.background) ?? [30, 30, 30]; + const color = rgb + ? `rgb(${Math.round(rgb[0] + (bg[0] - rgb[0]) * dim * 0.85)},${Math.round(rgb[1] + (bg[1] - rgb[1]) * dim * 0.85)},${Math.round(rgb[2] + (bg[2] - rgb[2]) * dim * 0.85)})` + : attrs.color; + return { ...attrs, color }; + }, + }); + } catch (err) { + // WebGL context unavailable — typically the browser has hit its per-tab + // context limit. Surface a graceful error instead of letting the crash + // bubble up to the route-level Error Boundary and blank the page. + console.error('LocalGraphView: failed to initialize graph renderer', err); + setError('Could not initialize the graph renderer. Try closing other tabs that use graph or 3D views, or reload the page.'); + graphRef.current = null; + return; + } sigmaRef.current = sigma; @@ -591,7 +623,7 @@ export function LocalGraphView() { return () => { if (hoverRaf !== null) cancelAnimationFrame(hoverRaf); - sigma.kill(); + releaseSigma(sigma, container); labelCanvas.remove(); sigmaRef.current = null; graphRef.current = null; From 28b5a2f391351d63b94adae2477195a1918a0a35 Mon Sep 17 00:00:00 2001 From: Kenny Bergquist Date: Sat, 16 May 2026 19:20:17 -0400 Subject: [PATCH 3/3] webgl cleanup followups --- src/components/canvas/LocalGraphView.tsx | 30 ++++++++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/components/canvas/LocalGraphView.tsx b/src/components/canvas/LocalGraphView.tsx index 075517f2..a096f9d3 100644 --- a/src/components/canvas/LocalGraphView.tsx +++ b/src/components/canvas/LocalGraphView.tsx @@ -22,14 +22,32 @@ const SIZE_DEPTH_2 = 7; * returns null and crashes on its first GL call (e.g. `gl.blendFunc`). * `WEBGL_lose_context.loseContext()` frees the slot immediately. */ +function releaseCanvasWebGlContext(canvas: HTMLCanvasElement) { + const gl = + (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ?? + (canvas.getContext('webgl') as WebGLRenderingContext | null); + gl?.getExtension('WEBGL_lose_context')?.loseContext(); +} + +function releaseCanvases(canvases: HTMLCanvasElement[]) { + for (const canvas of canvases) { + releaseCanvasWebGlContext(canvas); + } +} + function releaseSigma(sigma: Sigma, container: HTMLElement) { const canvases = Array.from(container.querySelectorAll('canvas')); sigma.kill(); - for (const canvas of canvases) { - const gl = - (canvas.getContext('webgl2') as WebGL2RenderingContext | null) ?? - (canvas.getContext('webgl') as WebGLRenderingContext | null); - gl?.getExtension('WEBGL_lose_context')?.loseContext(); + releaseCanvases(canvases); +} + +function releasePartialSigmaCanvases(container: HTMLElement, existingCanvases: Set) { + const partialCanvases = Array.from(container.querySelectorAll('canvas')).filter( + canvas => !existingCanvases.has(canvas) + ); + releaseCanvases(partialCanvases); + for (const canvas of partialCanvases) { + canvas.remove(); } } @@ -351,6 +369,7 @@ export function LocalGraphView() { neighborsRef.current = neighbors; let sigma: Sigma; + const existingCanvases = new Set(container.querySelectorAll('canvas')); try { sigma = new Sigma(g, container, { // Labels are rendered by our overlay canvas (always-on with collision avoidance). @@ -407,6 +426,7 @@ export function LocalGraphView() { // context limit. Surface a graceful error instead of letting the crash // bubble up to the route-level Error Boundary and blank the page. console.error('LocalGraphView: failed to initialize graph renderer', err); + releasePartialSigmaCanvases(container, existingCanvases); setError('Could not initialize the graph renderer. Try closing other tabs that use graph or 3D views, or reload the page.'); graphRef.current = null; return;