From 179640dd188b8640b52c7176909e230480884d50 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 14 Jul 2026 14:48:40 +1000 Subject: [PATCH] fix: throw WebGLUnsupportedError from Canvas.load() and make resize() safe without a renderer --- src/components/canvas/shotstack-canvas.ts | 7 ++- src/core/webgl-support.ts | 15 ++++++ src/index.ts | 1 + tests/webgl-support.test.ts | 64 +++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 tests/webgl-support.test.ts diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index f4a0d2ed..f975f5b1 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -5,7 +5,7 @@ import { Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; import { ms } from "@core/timing/types"; import type { UIController } from "@core/ui/ui-controller"; -import { checkWebGLSupport } from "@core/webgl-support"; +import { checkWebGLSupport, WebGLUnsupportedError } from "@core/webgl-support"; import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; import { FontLoadParser } from "@loaders/font-load-parser"; @@ -90,7 +90,7 @@ export class Canvas { const webglSupport = checkWebGLSupport(); if (!webglSupport.supported) { createWebGLErrorOverlay(root); - return; + throw new WebGLUnsupportedError(webglSupport.reason); } const rect = root.getBoundingClientRect(); @@ -268,6 +268,9 @@ export class Canvas { } public resize(): void { + // Renderer is undefined until load() initialises it and null after dispose(). + if (!this.application.renderer) return; + const root = document.querySelector(Canvas.CanvasSelector); if (!root) return; diff --git a/src/core/webgl-support.ts b/src/core/webgl-support.ts index a6d1a9d4..cd26c532 100644 --- a/src/core/webgl-support.ts +++ b/src/core/webgl-support.ts @@ -10,6 +10,21 @@ export interface WebGLSupportResult { reason?: "webgl-unavailable" | "webgl-error"; } +/** + * Thrown by `Canvas.load()` when WebGL is unavailable, so hosts can show + * their own error state instead of treating the editor as loaded. + */ +export class WebGLUnsupportedError extends Error { + constructor(reason?: WebGLSupportResult["reason"]) { + super( + reason === "webgl-error" + ? "WebGL initialisation failed. The editor cannot render in this browser." + : "WebGL is not available in this browser. Enable hardware acceleration or use a browser that supports WebGL." + ); + this.name = "WebGLUnsupportedError"; + } +} + /** * Check if WebGL is available in the current browser. * Tests both WebGL2 and WebGL1 for maximum compatibility. diff --git a/src/index.ts b/src/index.ts index 1fe2573b..95edc633 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { Controls } from "@core/inputs/controls"; export { VideoExporter } from "@core/export"; export { Timeline } from "@timeline/index"; export { UIController } from "@core/ui/ui-controller"; +export { WebGLUnsupportedError } from "@core/webgl-support"; export type { UIControllerOptions, ToolbarButtonConfig } from "@core/ui/ui-controller"; export type { EditConfig } from "@core/schemas"; diff --git a/tests/webgl-support.test.ts b/tests/webgl-support.test.ts new file mode 100644 index 00000000..703d9a70 --- /dev/null +++ b/tests/webgl-support.test.ts @@ -0,0 +1,64 @@ +/** + * @jest-environment jsdom + * + * WebGL Support Tests + * + * jsdom has no WebGL, so the unsupported path is the natural one here: + * load() must throw WebGLUnsupportedError (not silently succeed), and + * resize() must be a no-op before the renderer exists. + */ + +import { Canvas } from "@canvas/shotstack-canvas"; +import { checkWebGLSupport, WebGLUnsupportedError } from "@core/webgl-support"; + +import type { Edit } from "@core/edit-session"; + +// pixi.js ships untransformed ESM that jest can't parse; the paths under test +// (constructor + the WebGL guard at the top of load/resize) never reach a real renderer. +jest.mock("pixi.js", () => ({ + Application: jest.fn(), + Container: jest.fn(), + Graphics: jest.fn(), + Rectangle: jest.fn() +})); +jest.mock("pixi.js/app", () => ({})); +jest.mock("pixi.js/events", () => ({})); +jest.mock("pixi.js/graphics", () => ({})); +jest.mock("pixi.js/text", () => ({})); +jest.mock("pixi.js/text-html", () => ({})); +jest.mock("pixi.js/sprite-tiling", () => ({})); +jest.mock("pixi.js/filters", () => ({})); +jest.mock("pixi.js/mesh", () => ({})); + +const makeEditStub = (): Edit => + ({ + setCanvas: () => {}, + size: { width: 1280, height: 720 } + }) as unknown as Edit; + +describe("WebGL support", () => { + beforeEach(() => { + document.body.innerHTML = `
`; + }); + + it("reports unsupported in jsdom", () => { + expect(checkWebGLSupport().supported).toBe(false); + }); + + it("load() throws WebGLUnsupportedError when WebGL is unavailable", async () => { + const canvas = new Canvas(makeEditStub()); + await expect(canvas.load()).rejects.toBeInstanceOf(WebGLUnsupportedError); + }); + + it("still renders the error overlay for the user", async () => { + const canvas = new Canvas(makeEditStub()); + await canvas.load().catch(() => {}); + const root = document.querySelector("[data-shotstack-studio]"); + expect(root?.childElementCount).toBeGreaterThan(0); + }); + + it("resize() is a no-op before the renderer is initialised", () => { + const canvas = new Canvas(makeEditStub()); + expect(() => canvas.resize()).not.toThrow(); + }); +});