From e96b41fc04494366399fe9bf6b73271436d9e9c8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 29 Jun 2026 13:09:59 -0700 Subject: [PATCH] Degrade gracefully when WebGL2 is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole app rendered a blank page on browsers/configs that disable WebGL (e.g. Librewolf's per-domain allow list). new Canvas() ran during the synchronous SolidJS render and GLContext threw "WebGL2 not supported" when getContext("webgl2") returned null, aborting the entire render — even pages that don't need WebGL. Canvas now catches the failure, exposes a `supported` flag, and guards all GL operations so the decorative background simply doesn't render. The room (which needs WebGL for video) is gated behind canvas.supported and shows a clear "WebGL is required" notice instead of a blank page. Co-Authored-By: Claude Opus 4.8 --- app/src/room/canvas.ts | 43 ++++++++++++++++++++++++++++++------------ app/src/sup.tsx | 17 ++++++++++++++++- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/src/room/canvas.ts b/app/src/room/canvas.ts index e32f3a91..b3a4bfbb 100644 --- a/app/src/room/canvas.ts +++ b/app/src/room/canvas.ts @@ -7,9 +7,14 @@ import { GLContext } from "./gl/context"; export class Canvas { #canvas: HTMLCanvasElement; - #glContext: GLContext; - #camera: Camera; - #backgroundRenderer: BackgroundRenderer; + #glContext?: GLContext; + #camera?: Camera; + #backgroundRenderer?: BackgroundRenderer; + + // True when a WebGL2 context was successfully created. + // Some browsers (or privacy configurations) disable WebGL entirely, in which + // case we degrade gracefully instead of crashing the whole app. + readonly supported: boolean; // Use a callback to render after the background. onRender?: (now: DOMHighResTimeStamp) => void; @@ -24,14 +29,17 @@ export class Canvas { } get gl(): WebGL2RenderingContext { + if (!this.#glContext) throw new Error("WebGL2 not supported"); return this.#glContext.gl; } get glContext(): GLContext { + if (!this.#glContext) throw new Error("WebGL2 not supported"); return this.#glContext; } - get camera() { + get camera(): Camera { + if (!this.#camera) throw new Error("WebGL2 not supported"); return this.#camera; } @@ -41,10 +49,17 @@ export class Canvas { this.visible = new Signal(false); this.viewport = new Signal(Vector.create(0, 0)); - // Initialize WebGL2 context - this.#glContext = new GLContext(this.#canvas, this.viewport); - this.#camera = new Camera(); - this.#backgroundRenderer = new BackgroundRenderer(this.#glContext); + // Initialize WebGL2 context. This can fail when WebGL is disabled by the + // browser or a privacy extension; in that case we keep the app running + // without the GL-rendered background instead of throwing. + try { + this.#glContext = new GLContext(this.#canvas, this.viewport); + this.#camera = new Camera(); + this.#backgroundRenderer = new BackgroundRenderer(this.#glContext); + } catch (err) { + console.warn("WebGL2 unavailable, disabling canvas rendering", err); + } + this.supported = this.#glContext !== undefined; const resize = (entries: ResizeObserverEntry[]) => { for (const entry of entries) { @@ -68,14 +83,14 @@ export class Canvas { this.#canvas.height = newHeight; // Update WebGL viewport - this.#glContext.resize(newWidth, newHeight); + this.#glContext?.resize(newWidth, newHeight); // The internal logic ignores devicePixelRatio because we automatically scale when rendering. const viewport = Vector.create(newWidth / dpr, newHeight / dpr); this.viewport.set(viewport); // Update camera projection - this.#camera.updateOrtho(viewport); + this.#camera?.updateOrtho(viewport); // Render immediately to avoid black flicker during resize if (this.visible.peek()) { @@ -108,8 +123,10 @@ export class Canvas { resizeObserver.disconnect(); }); - // Only render the canvas when it's visible. + // Only render the canvas when it's visible (and WebGL is available). this.#signals.effect((effect) => { + if (!this.#glContext) return; + const visible = effect.get(this.visible); if (!visible) return; @@ -130,6 +147,8 @@ export class Canvas { } #render(now: DOMHighResTimeStamp) { + if (!this.#glContext || !this.#backgroundRenderer) return; + // Clear the screen this.#glContext.clear(); @@ -169,6 +188,6 @@ export class Canvas { close() { this.#signals.close(); - this.#backgroundRenderer.cleanup(); + this.#backgroundRenderer?.cleanup(); } } diff --git a/app/src/sup.tsx b/app/src/sup.tsx index 25009301..b3dc1c1a 100644 --- a/app/src/sup.tsx +++ b/app/src/sup.tsx @@ -62,11 +62,26 @@ export function Sup(props: { canvas: Canvas; room: string }): JSX.Element { return ( }> - + }> + + ); } +function WebGLUnsupported(): JSX.Element { + return ( + +

WebGL is required

+

+ This room needs WebGL to render video, but your browser has it disabled. Some browsers and privacy + extensions block WebGL by default to prevent fingerprinting. +

+

Please enable WebGL (for this site) and reload the page.

+
+ ); +} + function App(props: { connection: Moq.Connection.Reload; canvas: Canvas;