diff --git a/.github/workflows/unit-test-packages.yml b/.github/workflows/unit-test-packages.yml index 7147ceccd..fc1d32bfd 100644 --- a/.github/workflows/unit-test-packages.yml +++ b/.github/workflows/unit-test-packages.yml @@ -5,6 +5,9 @@ on: branches: [main] paths: - 'packages/**' + - 'tests/browser/**' + - 'playwright.config.ts' + - 'package.json' - bun.lock jobs: @@ -34,3 +37,9 @@ jobs: - name: Run tests run: bun test + + - name: Install Chromium + run: bunx playwright install --with-deps chromium + + - name: Run browser tests + run: bun run test:browser diff --git a/.gitignore b/.gitignore index c4686499c..3ef66a5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ storybook-static # Testing cypress/videos cypress/screenshots +playwright-report +test-results # Editor-specific .vscode diff --git a/bun.lock b/bun.lock index cfd0bf0b0..821149aac 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "paper-shaders", "devDependencies": { + "@playwright/test": "1.62.1", "@types/bun": "^1.1.11", "esbuild": "^0.24.0", "glob": "^11.0.1", @@ -319,6 +320,8 @@ "@paper-design/shaders-react": ["@paper-design/shaders-react@workspace:packages/shaders-react"], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + "@radix-ui/popper": ["@radix-ui/popper@0.1.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "csstype": "^3.0.4" } }, "sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ=="], "@radix-ui/primitive": ["@radix-ui/primitive@0.1.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-tqxZKybwN5Fa3VzZry4G6mXAAb9aAqKmPtnVbZpL0vsBwvOHTBwsjHVPXylocYLwEtBY9SCe665bYnNB515uoA=="], @@ -733,7 +736,7 @@ "fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1065,6 +1068,10 @@ "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], @@ -1449,6 +1456,8 @@ "chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "cosmiconfig/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], diff --git a/package.json b/package.json index 456b083d8..2bdc6105f 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "dev:docs": "bun run --filter docs dev", "dev:packages": "nodemon --watch 'packages/*/src/**/*' --ext ts,tsx --exec 'bun run build' --on-change-only", "dev": "run-s clean build && run-p dev:*", + "test:browser": "bun run build && playwright test", "clean": "rimraf packages/*/dist" }, "devDependencies": { + "@playwright/test": "1.62.1", "@types/bun": "^1.1.11", "esbuild": "^0.24.0", "nodemon": "^3.1.7", diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index 3a6f1be7c..d15560ffe 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -6,7 +6,9 @@ export class ShaderMount { public parentElement: PaperShaderElement; public canvasElement: HTMLCanvasElement; private gl: WebGL2RenderingContext; + private loseContextExtension: WEBGL_lose_context | null = null; private program: WebGLProgram | null = null; + private positionBuffer: WebGLBuffer | null = null; private uniformLocations: Record = {}; /** The fragment shader that we are using */ private fragmentShader: string; @@ -26,6 +28,8 @@ export class ShaderMount { private mipmaps: string[] = []; /** Just a sanity check to make sure frames don't run after we're disposed */ private hasBeenDisposed = false; + /** WebGL resources are invalid between context loss and restoration */ + private isContextLost = false; /** If the resolution of the canvas has changed since the last render */ private resolutionChanged = true; /** Store textures that are provided by the user */ @@ -96,13 +100,12 @@ export class ShaderMount { throw new Error('Paper Shaders: WebGL is not supported in this browser'); } this.gl = gl; + this.loseContextExtension = gl.getExtension('WEBGL_lose_context'); - this.initProgram(); - this.setupPositionAttribute(); - // Grab the locations of the uniforms in the fragment shader - this.setupUniforms(); - // Put the user provided values into the uniforms - this.setUniformValues(this.providedUniforms); + canvasElement.addEventListener('webglcontextlost', this.handleContextLost); + canvasElement.addEventListener('webglcontextrestored', this.handleContextRestored); + + this.initializeWebGLResourcesOrDispose(); // Set up the resize observer to handle window resizing and set u_resolution this.setupResizeObserver(); // Set up the visual viewport change listener to handle zoom changes (pinch zoom and classic browser zoom) @@ -123,6 +126,38 @@ export class ShaderMount { this.ownerDocument.addEventListener('visibilitychange', this.handleDocumentVisibilityChange); } + /** Create all state invalidated by a WebGL context loss */ + private initializeWebGLResourcesOrDispose = (): void => { + try { + this.initializeWebGLResources(); + } catch (error) { + this.dispose(); + throw error; + } + }; + + private initializeWebGLResources = (): void => { + this.program = null; + this.positionBuffer = null; + this.uniformLocations = {}; + this.uniformCache = {}; + this.textures.clear(); + + this.initProgram(); + if (!this.program) { + throw new Error('Paper Shaders: failed to initialize WebGL resources'); + } + + this.setupPositionAttribute(); + // Grab the locations of the uniforms in the fragment shader + this.setupUniforms(); + // Put the user provided values into the uniforms + this.setUniformValues(this.providedUniforms); + + this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + this.resolutionChanged = true; + }; + private initProgram = () => { const program = createProgram(this.gl, vertexShaderSource, this.fragmentShader); if (!program) return; @@ -131,7 +166,15 @@ export class ShaderMount { private setupPositionAttribute = () => { const positionAttributeLocation = this.gl.getAttribLocation(this.program!, 'a_position'); + if (positionAttributeLocation < 0) { + throw new Error('Paper Shaders: failed to locate the position attribute'); + } + const positionBuffer = this.gl.createBuffer(); + if (!positionBuffer) { + throw new Error('Paper Shaders: failed to create the position buffer'); + } + this.positionBuffer = positionBuffer; this.gl.bindBuffer(this.gl.ARRAY_BUFFER, positionBuffer); const positions = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]; this.gl.bufferData(this.gl.ARRAY_BUFFER, new Float32Array(positions), this.gl.STATIC_DRAW); @@ -283,7 +326,9 @@ export class ShaderMount { this.canvasElement.width = newWidth; this.canvasElement.height = newHeight; this.resolutionChanged = true; - this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + if (!this.isContextLost) { + this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + } // this is necessary to avoid flashes while resizing (the next scheduled render will set uniforms) this.render(performance.now()); @@ -291,7 +336,7 @@ export class ShaderMount { }; private render = (currentTime: number) => { - if (this.hasBeenDisposed) return; + if (this.hasBeenDisposed || this.isContextLost) return; if (this.program === null) { console.warn('Tried to render before program or gl was initialized'); @@ -333,6 +378,8 @@ export class ShaderMount { }; private requestRender = () => { + if (this.hasBeenDisposed || this.isContextLost) return; + if (this.rafId !== null) { cancelAnimationFrame(this.rafId); } @@ -349,6 +396,7 @@ export class ShaderMount { const existingTexture = this.textures.get(uniformName); if (existingTexture) { this.gl.deleteTexture(existingTexture); + this.textures.delete(uniformName); } // Get texture unit @@ -361,6 +409,9 @@ export class ShaderMount { // Create and set up the new texture const texture = this.gl.createTexture(); + if (!texture) { + throw new Error(`Paper Shaders: failed to create texture for uniform ${uniformName}`); + } this.gl.bindTexture(this.gl.TEXTURE_2D, texture); // Set texture parameters @@ -379,9 +430,9 @@ export class ShaderMount { } const error = this.gl.getError(); - if (error !== this.gl.NO_ERROR || texture === null) { - console.error('Paper Shaders: WebGL error when uploading texture:', error); - return; + if (error !== this.gl.NO_ERROR) { + this.gl.deleteTexture(texture); + throw new Error(`Paper Shaders: WebGL error ${error} when uploading texture for uniform ${uniformName}`); } // Store the texture @@ -435,7 +486,12 @@ export class ShaderMount { if (value instanceof HTMLImageElement) { // Texture case, requires a good amount of code so it gets its own function: - this.setTextureUniform(key, value); + try { + this.setTextureUniform(key, value); + } catch (error) { + delete this.uniformCache[key]; + throw error; + } } else if (Array.isArray(value)) { // Array case let flatArray: number[] | null = null; @@ -519,6 +575,8 @@ export class ShaderMount { private setCurrentSpeed = (newSpeed: number): void => { this.currentSpeed = newSpeed; + if (this.hasBeenDisposed || this.isContextLost) return; + if (this.rafId === null && newSpeed !== 0) { // Moving from 0 to animating, kick off a new rAF loop this.lastRenderTime = performance.now(); @@ -548,6 +606,13 @@ export class ShaderMount { /** Update the uniforms that are provided by the outside shader, can be a partial set with only the uniforms that have changed */ public setUniforms = (newUniforms: ShaderMountUniforms): void => { + if (this.hasBeenDisposed) return; + + if (this.isContextLost) { + this.providedUniforms = { ...this.providedUniforms, ...newUniforms }; + return; + } + this.setUniformValues(newUniforms); this.providedUniforms = { ...this.providedUniforms, ...newUniforms }; @@ -558,36 +623,105 @@ export class ShaderMount { this.updateCurrentSpeed(); }; + private handleContextLost = (event: Event) => { + if (this.hasBeenDisposed) return; + + // Opt this live mount into restoration. All logical state remains intact. + event.preventDefault(); + this.isContextLost = true; + + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + }; + + private handleContextRestored = () => { + if (this.hasBeenDisposed) return; + + try { + this.initializeWebGLResourcesOrDispose(); + } catch (error) { + // A restored context with unusable resources cannot receive another restore + // event. The initialization boundary has already disposed the mount. + console.error('Paper Shaders: failed to restore WebGL resources', error); + return; + } + this.isContextLost = false; + + // Re-evaluate visibility without advancing through the time spent context-lost. + this.updateCurrentSpeed(); + const currentTime = performance.now(); + this.lastRenderTime = currentTime; + this.render(currentTime); + }; + /** Dispose of the shader mount, cleaning up all of the WebGL resources */ public dispose = (): void => { + if (this.hasBeenDisposed) return; + // Immediately mark as disposed to prevent future renders from leaking in this.hasBeenDisposed = true; + // Remove lifecycle listeners before explicitly losing the context so disposal + // cannot opt the context into restoration or recreate resources. + this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); + this.canvasElement.removeEventListener('webglcontextrestored', this.handleContextRestored); + + if (this.gl.isContextLost() && this.loseContextExtension) { + const loseContextExtension = this.loseContextExtension; + // A real context loss may already be queued for restoration because the live + // mount prevented its default. We cannot cancel that restore while still lost, + // so terminally lose it again if the browser revives the disposed context. + // This one-shot listener retains only the canvas and extension, not the mount. + this.canvasElement.addEventListener( + 'webglcontextrestored', + () => { + // The context cannot be lost again until the restore event has finished. + queueMicrotask(() => loseContextExtension.loseContext()); + }, + { capture: true, once: true } + ); + } + // Cancel the rAF loop if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } - if (this.gl && this.program) { - // Clean up all textures - this.textures.forEach((texture) => { - this.gl.deleteTexture(texture); - }); - this.textures.clear(); + // Clean up all textures + this.textures.forEach((texture) => { + this.gl.deleteTexture(texture); + }); + this.textures.clear(); + + if (this.positionBuffer) { + this.gl.deleteBuffer(this.positionBuffer); + this.positionBuffer = null; + } + if (this.program) { this.gl.deleteProgram(this.program); this.program = null; + } - // Reset the WebGL context - this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null); - this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null); - this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, null); - this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); - - // Clear any errors - this.gl.getError(); + // Reset the WebGL context + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null); + this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null); + this.gl.bindRenderbuffer(this.gl.RENDERBUFFER, null); + this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null); + + // Clear any errors, then terminally relinquish the underlying context. + this.gl.getError(); + if (!this.gl.isContextLost() && this.loseContextExtension) { + const loseContextExtension = this.loseContextExtension; + // Disposal can run inside webglcontextrestored, where another loss is + // invalid until event dispatch completes. A microtask also keeps ordinary + // disposal terminal within the current task. + queueMicrotask(() => loseContextExtension.loseContext()); } + this.isContextLost = true; if (this.resizeObserver) { this.resizeObserver.disconnect(); @@ -607,7 +741,9 @@ export class ShaderMount { // Remove the shader from the div wrapper element this.canvasElement.remove(); // Free up the reference to self to enable garbage collection - delete this.parentElement.paperShaderMount; + if (this.parentElement.paperShaderMount === this) { + delete this.parentElement.paperShaderMount; + } }; } @@ -646,10 +782,18 @@ function createProgram( const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource); const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); - if (!vertexShader || !fragmentShader) return null; + if (!vertexShader || !fragmentShader) { + if (vertexShader) gl.deleteShader(vertexShader); + if (fragmentShader) gl.deleteShader(fragmentShader); + return null; + } const program = gl.createProgram(); - if (!program) return null; + if (!program) { + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + return null; + } gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..4fc77c838 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/browser', + testMatch: '**/*.pw.ts', + fullyParallel: false, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + reporter: process.env.CI ? 'line' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + launchOptions: { + args: ['--enable-unsafe-swiftshader', '--use-angle=swiftshader'], + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'bun tests/browser/server.ts', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/browser/server.ts b/tests/browser/server.ts new file mode 100644 index 000000000..0d8eda9cf --- /dev/null +++ b/tests/browser/server.ts @@ -0,0 +1,25 @@ +import { resolve, sep } from 'node:path'; + +const rootDirectory = resolve(import.meta.dir, '../..'); +const fixturePath = resolve(import.meta.dir, 'shader-mount.html'); + +Bun.serve({ + hostname: '127.0.0.1', + port: 4173, + async fetch(request) { + const url = new URL(request.url); + const requestedPath = + url.pathname === '/' ? fixturePath : resolve(rootDirectory, `.${decodeURIComponent(url.pathname)}`); + + if (requestedPath !== fixturePath && !requestedPath.startsWith(`${rootDirectory}${sep}`)) { + return new Response('Not found', { status: 404 }); + } + + const file = Bun.file(requestedPath); + if (!(await file.exists())) return new Response('Not found', { status: 404 }); + + return new Response(file); + }, +}); + +console.log('Paper Shaders browser test server listening on http://127.0.0.1:4173'); diff --git a/tests/browser/shader-mount.html b/tests/browser/shader-mount.html new file mode 100644 index 000000000..44c99fefa --- /dev/null +++ b/tests/browser/shader-mount.html @@ -0,0 +1,27 @@ + + + + + + ShaderMount WebGL lifecycle test + + + +
+
+ + diff --git a/tests/browser/shader-mount.pw.ts b/tests/browser/shader-mount.pw.ts new file mode 100644 index 000000000..9d7e3e1da --- /dev/null +++ b/tests/browser/shader-mount.pw.ts @@ -0,0 +1,497 @@ +import { expect, test } from '@playwright/test'; + +const fragmentShader = `#version 300 es +precision highp float; + +uniform float u_time; +uniform float u_value; +uniform sampler2D u_image; + +out vec4 fragColor; + +void main() { + vec4 imageColor = texture(u_image, vec2(.5)); + fragColor = vec4(u_value, imageColor.g, fract(u_time), 1.); +}`; + +test('rebuilds resources and resumes retained state after context restoration', async ({ page }) => { + await page.goto('/'); + + const initialized = await page.evaluate(async (shader) => { + const { ShaderMount } = await import('/packages/shaders/dist/index.js'); + const parent = document.querySelector('#mount')!; + + const createImage = async (color: string) => { + const source = document.createElement('canvas'); + source.width = 1; + source.height = 1; + const context = source.getContext('2d')!; + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + + const image = new Image(); + image.src = source.toDataURL(); + await image.decode(); + return image; + }; + + const image = await createImage('#0000ff'); + const mount = new ShaderMount( + parent, + shader, + { u_value: 0.25, u_image: image }, + { preserveDrawingBuffer: true }, + 1, + 100, + 1, + 1_000_000 + ); + const canvas = mount.canvasElement; + const gl = canvas.getContext('webgl2')!; + const loseContext = gl.getExtension('WEBGL_lose_context'); + + if (!loseContext) throw new Error('WEBGL_lose_context is unavailable'); + + Object.assign(window, { + shaderLifecycleTest: { mount, canvas, gl, loseContext, createImage }, + }); + + return { width: canvas.width, height: canvas.height }; + }, fragmentShader); + + expect(initialized.width).toBeGreaterThan(0); + expect(initialized.height).toBeGreaterThan(0); + await expect + .poll(() => page.evaluate(() => (window as any).shaderLifecycleTest.mount.getCurrentFrame())) + .toBeGreaterThan(100); + await page.waitForFunction(() => (window as any).shaderLifecycleTest.canvas.width === 64); + + const lossState = await page.evaluate(async () => { + const state = (window as any).shaderLifecycleTest; + state.oldProgram = state.gl.getParameter(state.gl.CURRENT_PROGRAM); + state.oldBuffer = state.gl.getParameter(state.gl.ARRAY_BUFFER_BINDING); + state.oldTexture = state.gl.getParameter(state.gl.TEXTURE_BINDING_2D); + + const lost = new Promise((resolve) => { + state.canvas.addEventListener('webglcontextlost', (event: Event) => resolve(event.defaultPrevented), { + once: true, + }); + }); + + state.loseContext.loseContext(); + const defaultPrevented = await lost; + const frame = state.mount.getCurrentFrame(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + return { + defaultPrevented, + isContextLost: state.gl.isContextLost(), + frame, + frameAfterWait: state.mount.getCurrentFrame(), + }; + }); + + expect(lossState.defaultPrevented).toBe(true); + expect(lossState.isContextLost).toBe(true); + expect(lossState.frameAfterWait).toBe(lossState.frame); + + await page.evaluate(async () => { + const state = (window as any).shaderLifecycleTest; + const image = await state.createImage('#00ff00'); + + state.mount.setFrame(750); + state.mount.setSpeed(0.75); + state.mount.setUniforms({ u_value: 0.8, u_image: image }); + + const parent = document.querySelector('#mount')!; + parent.style.width = '96px'; + parent.style.height = '80px'; + }); + await page.waitForFunction(() => { + const canvas = (window as any).shaderLifecycleTest.canvas; + return canvas.width === 96 && canvas.height === 80; + }); + + await page.evaluate( + () => + new Promise((resolve) => { + const parent = document.querySelector('#mount')!; + const observer = new IntersectionObserver(([entry]) => { + if (entry && !entry.isIntersecting) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(parent); + window.scrollTo(0, document.body.scrollHeight); + }) + ); + + const restoredState = await page.evaluate(async () => { + const state = (window as any).shaderLifecycleTest; + const restored = new Promise((resolve) => { + state.canvas.addEventListener('webglcontextrestored', () => resolve(), { once: true }); + }); + + state.loseContext.restoreContext(); + await restored; + await new Promise((resolve) => setTimeout(resolve, 100)); + + const pixel = new Uint8Array(4); + state.gl.readPixels(0, 0, 1, 1, state.gl.RGBA, state.gl.UNSIGNED_BYTE, pixel); + + return { + isContextLost: state.gl.isContextLost(), + frame: state.mount.getCurrentFrame(), + pixel: Array.from(pixel), + viewport: Array.from(state.gl.getParameter(state.gl.VIEWPORT) as Int32Array), + programRecreated: state.gl.getParameter(state.gl.CURRENT_PROGRAM) !== state.oldProgram, + bufferRecreated: state.gl.getParameter(state.gl.ARRAY_BUFFER_BINDING) !== state.oldBuffer, + textureRecreated: state.gl.getParameter(state.gl.TEXTURE_BINDING_2D) !== state.oldTexture, + }; + }); + + expect(restoredState.isContextLost).toBe(false); + expect(restoredState.frame).toBe(750); + expect(restoredState.viewport).toEqual([0, 0, 96, 80]); + expect(restoredState.programRecreated).toBe(true); + expect(restoredState.bufferRecreated).toBe(true); + expect(restoredState.textureRecreated).toBe(true); + expect(restoredState.pixel[0]).toBeGreaterThanOrEqual(202); + expect(restoredState.pixel[0]).toBeLessThanOrEqual(206); + expect(restoredState.pixel[1]).toBeGreaterThanOrEqual(253); + expect(restoredState.pixel[2]).toBeGreaterThanOrEqual(189); + expect(restoredState.pixel[2]).toBeLessThanOrEqual(193); + expect(restoredState.pixel[3]).toBe(255); + + await page.evaluate(() => window.scrollTo(0, 0)); + await expect + .poll(() => page.evaluate(() => (window as any).shaderLifecycleTest.mount.getCurrentFrame())) + .toBeGreaterThan(750); +}); + +test('does not construct a live mount when initial WebGL resources cannot be built', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate(async (shader) => { + const { ShaderMount } = await import('/packages/shaders/dist/index.js'); + const parent = document.querySelector('#mount')! as HTMLElement & { + paperShaderMount?: InstanceType; + }; + const canvasPrototype = WebGL2RenderingContext.prototype; + const originalCreateProgram = canvasPrototype.createProgram; + let errorMessage: string | null = null; + let textureErrorMessage: string | null = null; + + try { + canvasPrototype.createProgram = () => null; + new ShaderMount(parent, shader, { u_value: 0.5 }); + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + } finally { + canvasPrototype.createProgram = originalCreateProgram; + } + + try { + new ShaderMount(parent, shader, { u_value: 0.5, u_image: new Image() }); + } catch (error) { + textureErrorMessage = error instanceof Error ? error.message : String(error); + } + + await new Promise((resolve) => setTimeout(resolve, 0)); + + return { + errorMessage, + textureErrorMessage, + canvasCount: parent.querySelectorAll('canvas').length, + mountAttached: parent.paperShaderMount !== undefined, + }; + }, fragmentShader); + + expect(result.errorMessage).toBe('Paper Shaders: failed to initialize WebGL resources'); + expect(result.textureErrorMessage).toBe('Paper Shaders: image for uniform u_image must be fully loaded'); + expect(result.canvasCount).toBe(0); + expect(result.mountAttached).toBe(false); +}); + +for (const failedResource of ['program', 'buffer', 'texture'] as const) { + test(`fails closed when restored WebGL ${failedResource} cannot be rebuilt`, async ({ page }) => { + const renderWarnings: string[] = []; + page.on('console', (message) => { + if (message.text().includes('Tried to render before program or gl was initialized')) { + renderWarnings.push(message.text()); + } + }); + + await page.goto('/'); + + const result = await page.evaluate( + async ({ shader, failedResource }) => { + const { ShaderMount } = await import('/packages/shaders/dist/index.js'); + const parent = document.querySelector('#mount')! as HTMLElement & { + paperShaderMount?: InstanceType; + }; + + const source = document.createElement('canvas'); + source.width = 1; + source.height = 1; + source.getContext('2d')!.fillRect(0, 0, 1, 1); + const image = new Image(); + image.src = source.toDataURL(); + await image.decode(); + + const mount = new ShaderMount( + parent, + shader, + { u_value: 0.5, u_image: image }, + { preserveDrawingBuffer: true }, + 1 + ); + const canvas = mount.canvasElement; + const gl = canvas.getContext('webgl2')!; + const loseContext = gl.getExtension('WEBGL_lose_context'); + if (!loseContext) throw new Error('WEBGL_lose_context is unavailable'); + + const lost = new Promise((resolve) => { + canvas.addEventListener('webglcontextlost', () => resolve(), { once: true }); + }); + loseContext.loseContext(); + await lost; + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Force exactly one restored resource factory to fail. Construction and + // the browser's context restoration have already succeeded. + const method = + failedResource === 'program' + ? 'createProgram' + : failedResource === 'buffer' + ? 'createBuffer' + : 'createTexture'; + Object.defineProperty(gl, method, { configurable: true, value: () => null }); + + let restorationCount = 0; + const restored = new Promise((resolve) => { + canvas.addEventListener( + 'webglcontextrestored', + () => { + restorationCount += 1; + resolve(); + }, + { once: true } + ); + }); + const terminalLoss = new Promise((resolve) => { + canvas.addEventListener('webglcontextlost', () => resolve(), { once: true }); + }); + + loseContext.restoreContext(); + await restored; + const terminallyLost = await Promise.race([ + terminalLoss.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 500)), + ]); + + mount.setSpeed(1); + await new Promise((resolve) => setTimeout(resolve, 100)); + + return { + terminallyLost, + contextLost: gl.isContextLost(), + canvasConnected: canvas.isConnected, + mountStillAttached: parent.paperShaderMount === mount, + restorationCount, + }; + }, + { shader: fragmentShader, failedResource } + ); + + expect(result.terminallyLost).toBe(true); + expect(result.contextLost).toBe(true); + expect(result.canvasConnected).toBe(false); + expect(result.mountStillAttached).toBe(false); + expect(result.restorationCount).toBe(1); + expect(renderWarnings).toEqual([]); + }); +} + +test('fails closed when retained uniforms throw during restoration', async ({ page }) => { + await page.goto('/'); + + const result = await page.evaluate(async (shader) => { + const { ShaderMount } = await import('/packages/shaders/dist/index.js'); + const parent = document.querySelector('#mount')! as HTMLElement & { + paperShaderMount?: InstanceType; + }; + + const source = document.createElement('canvas'); + source.width = 1; + source.height = 1; + source.getContext('2d')!.fillRect(0, 0, 1, 1); + const loadedImage = new Image(); + loadedImage.src = source.toDataURL(); + await loadedImage.decode(); + + const mount = new ShaderMount( + parent, + shader, + { u_value: 0.5, u_image: loadedImage }, + { preserveDrawingBuffer: true }, + 1 + ); + const canvas = mount.canvasElement; + const gl = canvas.getContext('webgl2')!; + const loseContext = gl.getExtension('WEBGL_lose_context'); + if (!loseContext) throw new Error('WEBGL_lose_context is unavailable'); + + const lost = new Promise((resolve) => { + canvas.addEventListener('webglcontextlost', () => resolve(), { once: true }); + }); + loseContext.loseContext(); + await lost; + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Uniform updates during loss are retained without touching invalid WebGL + // state. This unloaded image throws only when restoration reapplies it. + mount.setUniforms({ u_image: new Image() }); + + const restored = new Promise((resolve) => { + canvas.addEventListener('webglcontextrestored', () => resolve(), { once: true }); + }); + const terminalLoss = new Promise((resolve) => { + canvas.addEventListener('webglcontextlost', () => resolve(), { once: true }); + }); + + loseContext.restoreContext(); + await restored; + const terminallyLost = await Promise.race([ + terminalLoss.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 500)), + ]); + + return { + terminallyLost, + contextLost: gl.isContextLost(), + canvasConnected: canvas.isConnected, + mountStillAttached: parent.paperShaderMount === mount, + }; + }, fragmentShader); + + expect(result.terminallyLost).toBe(true); + expect(result.contextLost).toBe(true); + expect(result.canvasConnected).toBe(false); + expect(result.mountStillAttached).toBe(false); +}); + +test('terminal disposal loses every remounted context without restoration or accumulation', async ({ page }) => { + const contextWarnings: string[] = []; + page.on('console', (message) => { + if (message.text().includes('Too many active WebGL contexts')) contextWarnings.push(message.text()); + }); + + await page.goto('/'); + + const result = await page.evaluate( + async ({ shader, mountCount }) => { + const { ShaderMount } = await import('/packages/shaders/dist/index.js'); + const parent = document.querySelector('#mount')! as HTMLElement & { + paperShaderMount?: InstanceType; + }; + const contexts: WebGL2RenderingContext[] = []; + const restoredCounts: number[] = []; + const lossDefaultPrevented: boolean[] = []; + let previousMount: InstanceType | null = null; + + const lostMount = new ShaderMount(parent, shader, { u_value: 0.5 }, { preserveDrawingBuffer: true }, 1); + const lostCanvas = lostMount.canvasElement; + const lostContext = lostCanvas.getContext('webgl2')!; + const lostContextExtension = lostContext.getExtension('WEBGL_lose_context'); + if (!lostContextExtension) throw new Error('WEBGL_lose_context is unavailable'); + + const initialLoss = new Promise((resolve) => { + lostCanvas.addEventListener('webglcontextlost', (event) => resolve(event.defaultPrevented), { once: true }); + }); + lostContextExtension.loseContext(); + const initialLossDefaultPrevented = await initialLoss; + // restoreContext() is invalid until the context-lost event task has completed. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const restoredAfterDispose = new Promise((resolve) => { + lostCanvas.addEventListener('webglcontextrestored', () => resolve(), { once: true }); + }); + const terminalLoss = new Promise((resolve) => { + lostCanvas.addEventListener('webglcontextlost', (event) => resolve(event.defaultPrevented), { once: true }); + }); + + // Queue restoration first, then dispose before its event task runs. + lostContextExtension.restoreContext(); + lostMount.dispose(); + await restoredAfterDispose; + const terminalLossDefaultPrevented = await Promise.race([ + terminalLoss, + new Promise((resolve) => setTimeout(() => resolve(null), 500)), + ]); + + for (let index = 0; index < mountCount; index += 1) { + const mount = new ShaderMount(parent, shader, { u_value: 0.5 }, { preserveDrawingBuffer: true }, 1); + const canvas = mount.canvasElement; + const gl = canvas.getContext('webgl2')!; + const loseContext = gl.getExtension('WEBGL_lose_context'); + if (!loseContext) throw new Error('WEBGL_lose_context is unavailable'); + + // React effect cleanup may invoke an already-disposed instance after a remount. + previousMount?.dispose(); + if (parent.paperShaderMount !== mount) throw new Error('A stale dispose removed the live remount reference'); + + restoredCounts[index] = 0; + canvas.addEventListener('webglcontextrestored', () => { + restoredCounts[index] = (restoredCounts[index] ?? 0) + 1; + }); + const lost = new Promise((resolve) => { + canvas.addEventListener('webglcontextlost', (event) => resolve(event.defaultPrevented), { once: true }); + }); + + mount.dispose(); + lossDefaultPrevented.push(await lost); + contexts.push(gl); + + if (canvas.isConnected) throw new Error('Disposed canvas is still connected'); + if (parent.querySelector('canvas')) throw new Error('Disposed canvas remains under the mount element'); + if (parent.paperShaderMount !== undefined) throw new Error('Disposed mount remains attached to its parent'); + + previousMount = mount; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const finalMount = new ShaderMount(parent, shader, { u_value: 0.5 }, { preserveDrawingBuffer: true }, 1, 10); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + const finalFrame = finalMount.getCurrentFrame(); + const finalContextLost = finalMount.canvasElement.getContext('webgl2')!.isContextLost(); + finalMount.dispose(); + + return { + initialLossDefaultPrevented, + terminalLossDefaultPrevented, + disposedDuringLossContextLost: lostContext.isContextLost(), + allDisposedContextsLost: contexts.every((context) => context.isContextLost()), + restoredCounts, + lossDefaultPrevented, + finalFrame, + finalContextLost, + remainingCanvases: parent.querySelectorAll('canvas').length, + }; + }, + { shader: fragmentShader, mountCount: 24 } + ); + + expect(result.initialLossDefaultPrevented).toBe(true); + expect(result.terminalLossDefaultPrevented).toBe(false); + expect(result.disposedDuringLossContextLost).toBe(true); + expect(result.allDisposedContextsLost).toBe(true); + expect(result.restoredCounts).toEqual(Array(24).fill(0)); + expect(result.lossDefaultPrevented).toEqual(Array(24).fill(false)); + expect(result.finalFrame).toBeGreaterThan(10); + expect(result.finalContextLost).toBe(false); + expect(result.remainingCanvases).toBe(0); + expect(contextWarnings).toEqual([]); +});