From 9a2b2028ac140e47b6028a83f276fcf36073ca3b Mon Sep 17 00:00:00 2001 From: Wildan Muhlis Date: Mon, 10 Aug 2026 19:48:27 +0700 Subject: [PATCH] =?UTF-8?q?fix(=F0=9F=90=9B):=20don't=20crash=20the=20web?= =?UTF-8?q?=20view=20when=20no=20WebGL=20surface=20can=20be=20created?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On web, WebGLRenderer throws when WebGL is unavailable (hardware acceleration disabled, blocklisted GPU, or the per-page context limit exhausted). The constructor throw escapes the layout effect that builds the renderer, and the onResize throw escapes the ResizeObserver callback, where no try/catch or React error boundary can reach it - so an environment problem becomes an app crash. Degrade to an inert renderer instead: draw() and makeImageSnapshot() already no-op on a null surface, matching how StaticWebGLRenderer treats a failed surface as recoverable. A bubbling "skia-surface-unavailable" CustomEvent is dispatched on the canvas so embedders can show a fallback UI; without a listener it is a no-op. --- .../skia/src/views/SkiaPictureView.web.tsx | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/skia/src/views/SkiaPictureView.web.tsx b/packages/skia/src/views/SkiaPictureView.web.tsx index 94a0511f5f..bbacb14e96 100644 --- a/packages/skia/src/views/SkiaPictureView.web.tsx +++ b/packages/skia/src/views/SkiaPictureView.web.tsx @@ -45,13 +45,20 @@ class WebGLRenderer implements Renderer { constructor(private canvas: HTMLCanvasElement) { this.contextHandle = CanvasKit.GetWebGLContext(canvas); if (!this.contextHandle) { - throw new Error("Could not create a WebGL context"); + // WebGL is unavailable (disabled, blocklisted GPU, or the per-page + // context limit is exhausted). Throwing here would escape the layout + // effect that constructs the renderer, so degrade to an inert renderer + // instead: draw() and makeImageSnapshot() already no-op on a null + // surface, and the event lets embedders show a fallback UI. + this.announceSurfaceUnavailable(); + return; } this.grContext = CanvasKit.MakeWebGLContext(this.contextHandle); if (!this.grContext) { CanvasKit.deleteContext(this.contextHandle); this.contextHandle = 0; - throw new Error("Could not create a graphics context"); + this.announceSurfaceUnavailable(); + return; } const ctx = canvas.getContext("webgl2"); if (ctx) { @@ -60,6 +67,20 @@ class WebGLRenderer implements Renderer { this.onResize(); } + // Announces that no drawing surface could be created, on the next frame so + // listeners attached in an effect of the same commit that mounted the view + // are registered first. Bubbles so embedders can react (e.g. swap in a + // fallback UI); without a listener it is a no-op. Mirrors how + // StaticWebGLRenderer already treats a failed surface as recoverable + // rather than fatal. + private announceSurfaceUnavailable() { + requestAnimationFrame(() => { + this.canvas.dispatchEvent( + new CustomEvent("skia-surface-unavailable", { bubbles: true }) + ); + }); + } + makeImageSnapshot(picture: SkPicture, rect?: SkRect): SkImage | null { if (!this.surface) { return null; @@ -95,7 +116,12 @@ class WebGLRenderer implements Renderer { CanvasKit.ColorSpace.SRGB ); if (!surface) { - throw new Error("Could not create surface"); + // onResize runs from a ResizeObserver callback, so a throw here escapes + // as an unhandled error that no try/catch or React error boundary can + // reach. Leave the surface null instead — draw() and makeImageSnapshot() + // already no-op on it — and let embedders know. + this.announceSurfaceUnavailable(); + return; } this.surface = new JsiSkSurface(CanvasKit, surface); }