From 05aef664a3280d4f2206221c07dcaf07230a36b9 Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 12:45:41 +0100 Subject: [PATCH 1/9] test page --- docs/src/app/test/page.tsx | 75 ++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/src/app/test/page.tsx b/docs/src/app/test/page.tsx index d6dbe70d..712e1154 100644 --- a/docs/src/app/test/page.tsx +++ b/docs/src/app/test/page.tsx @@ -1,13 +1,72 @@ 'use client'; -import { PaperTexture } from '@paper-design/shaders-react'; +import { + ColorPanels, + Dithering, + DotGrid, + DotOrbit, + FlutedGlass, + GodRays, + GrainGradient, + HalftoneCmyk, + HalftoneDots, + Heatmap, + ImageDithering, + LiquidMetal, + MeshGradient, + Metaballs, + NeuroNoise, + PaperTexture, + PerlinNoise, + PulsingBorder, + SimplexNoise, + SmokeRing, + Spiral, + StaticMeshGradient, + StaticRadialGradient, + Swirl, + Voronoi, + Warp, + Water, + Waves, +} from '@paper-design/shaders-react'; + +const sampleImage = 'https://shaders.paper.design/images/image-filters/0018.webp'; +const sampleSvg = 'https://shaders.paper.design/images/logos/diamond.svg'; export default function TestPage() { - return ( -
- - - -
- ); + return ( +
+ + + + + + + + + + {/* Non-image shaders */} + + + + + + + + + + + + + + + + + + + + +
+ ); } From 1c7a9dd726e6c9af5c7e13682d9ba495b27d74aa Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 13:00:42 +0100 Subject: [PATCH 2/9] webgl context events handlers --- packages/shaders/src/shader-mount.ts | 95 ++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index b327d682..f7143dc7 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -35,6 +35,8 @@ export class ShaderMount { private isSafari = isSafari(); private uniformCache: Record = {}; private textureUnitMap: Map = new Map(); + private contextIsLost = false; + private placeholderElement: HTMLDivElement | null = null; constructor( /** The div you'd like to mount the shader to. The shader will match its size. */ @@ -115,6 +117,11 @@ export class ShaderMount { // Listen for document visibility changes to pause the shader when the tab is hidden document.addEventListener('visibilitychange', this.handleDocumentVisibilityChange); + + // Handle WebGL context loss (browsers silently evict contexts when too many are active) + this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); + this.canvasElement.addEventListener('webglcontextrestored', this.handleContextRestored); + } private initProgram = () => { @@ -270,6 +277,14 @@ export class ShaderMount { private render = (currentTime: number) => { if (this.hasBeenDisposed) return; + if (this.contextIsLost) return; + + // Detect context loss before the async event fires, so we never show a blank canvas + if (this.gl.isContextLost()) { + this.contextIsLost = true; + this.showPlaceholder(); + return; + } if (this.program === null) { console.warn('Tried to render before program or gl was initialized'); @@ -317,6 +332,61 @@ export class ShaderMount { this.rafId = requestAnimationFrame(this.render); }; + private handleContextLost = (e: Event): void => { + e.preventDefault(); + this.contextIsLost = true; + + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + + this.showPlaceholder(); + }; + + private handleContextRestored = (): void => { + this.contextIsLost = false; + this.hidePlaceholder(); + + // Re-init all GL state on the restored context + this.initProgram(); + this.setupPositionAttribute(); + this.setupUniforms(); + this.uniformCache = {}; + this.textureUnitMap.clear(); + this.textures.clear(); + this.setUniformValues(this.providedUniforms); + this.resolutionChanged = true; + this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + + this.lastRenderTime = performance.now(); + this.render(performance.now()); + if (this.currentSpeed !== 0) { + this.requestRender(); + } + }; + + private showPlaceholder = (): void => { + if (this.placeholderElement) return; + + this.placeholderElement = document.createElement('div'); + this.placeholderElement.setAttribute('data-paper-shader-placeholder', ''); + this.placeholderElement.textContent = 'WebGL context limit reached'; + this.placeholderElement.style.opacity = '0'; + this.canvasElement.style.display = 'none'; + this.parentElement.prepend(this.placeholderElement); + // Trigger transition on next frame + requestAnimationFrame(() => { + if (this.placeholderElement) this.placeholderElement.style.opacity = '1'; + }); + }; + + private hidePlaceholder = (): void => { + this.canvasElement.style.display = ''; + this.placeholderElement?.remove(); + this.placeholderElement = null; + }; + /** Creates a texture from an image and sets it into a uniform value */ private setTextureUniform = (uniformName: string, image: HTMLImageElement): void => { if (!image.complete || image.naturalWidth === 0) { @@ -569,9 +639,15 @@ export class ShaderMount { visualViewport?.removeEventListener('resize', this.handleVisualViewportChange); document.removeEventListener('visibilitychange', this.handleDocumentVisibilityChange); + this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); + this.canvasElement.removeEventListener('webglcontextrestored', this.handleContextRestored); this.uniformLocations = {}; + // Clean up placeholder if present + this.placeholderElement?.remove(); + this.placeholderElement = null; + // Remove the shader from the div wrapper element this.canvasElement.remove(); // Free up the reference to self to enable garbage collection @@ -656,6 +732,25 @@ const defaultStyle = `@layer paper-shaders { border-radius: inherit; corner-shape: inherit; } + + & [data-paper-shader-placeholder] { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + border-radius: inherit; + corner-shape: inherit; + background: rgba(0, 0, 0, 0.5); + color: rgba(255, 255, 255, 0.5); + font: 13px/1 system-ui, sans-serif; + outline: 1px solid rgba(255, 255, 255, 0.1); + outline-offset: -1px; + transition: opacity 150ms ease; + } } }`; From 02ac1f341ee8a59e4d94f7098d43904a4a0f44e2 Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 13:24:26 +0100 Subject: [PATCH 3/9] test page update --- docs/src/app/test/page.tsx | 93 +++++++++++++++++++++++++------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/docs/src/app/test/page.tsx b/docs/src/app/test/page.tsx index 712e1154..395f52d3 100644 --- a/docs/src/app/test/page.tsx +++ b/docs/src/app/test/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState, useCallback } from 'react'; import { ColorPanels, Dithering, @@ -30,43 +31,73 @@ import { Water, Waves, } from '@paper-design/shaders-react'; +import type { ReactElement } from 'react'; const sampleImage = 'https://shaders.paper.design/images/image-filters/0018.webp'; const sampleSvg = 'https://shaders.paper.design/images/logos/diamond.svg'; +const shaderFactories: Array<(key: string) => ReactElement> = [ + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , + (key) => , +]; + +let nextId = 0; + export default function TestPage() { - return ( -
- - - - - - - - + const [shaders, setShaders] = useState([]); - {/* Non-image shaders */} - - - - - - - - - - - - - - - - - - - - + const addShader = useCallback(() => { + const factory = shaderFactories[Math.floor(Math.random() * shaderFactories.length)]!; + const id = `shader-${nextId++}`; + setShaders((prev) => [...prev, factory(id)]); + }, []); + + const removeShader = useCallback(() => { + setShaders((prev) => prev.slice(0, -1)); + }, []); + + return ( +
+
+ + + {shaders.length} shaders +
+
{shaders}
); } From ec7c019b91afad498b66660d7627aacbfbb9a7c5 Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 13:40:19 +0100 Subject: [PATCH 4/9] use CSS after instead of separate div for a placeholder --- packages/shaders/src/shader-mount.ts | 30 ++++++++++------------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index f7143dc7..d7055055 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -36,7 +36,6 @@ export class ShaderMount { private uniformCache: Record = {}; private textureUnitMap: Map = new Map(); private contextIsLost = false; - private placeholderElement: HTMLDivElement | null = null; constructor( /** The div you'd like to mount the shader to. The shader will match its size. */ @@ -367,24 +366,13 @@ export class ShaderMount { }; private showPlaceholder = (): void => { - if (this.placeholderElement) return; - - this.placeholderElement = document.createElement('div'); - this.placeholderElement.setAttribute('data-paper-shader-placeholder', ''); - this.placeholderElement.textContent = 'WebGL context limit reached'; - this.placeholderElement.style.opacity = '0'; - this.canvasElement.style.display = 'none'; - this.parentElement.prepend(this.placeholderElement); - // Trigger transition on next frame - requestAnimationFrame(() => { - if (this.placeholderElement) this.placeholderElement.style.opacity = '1'; - }); + this.canvasElement.style.visibility = 'hidden'; + this.parentElement.setAttribute('data-paper-shader-placeholder', ''); }; private hidePlaceholder = (): void => { - this.canvasElement.style.display = ''; - this.placeholderElement?.remove(); - this.placeholderElement = null; + this.canvasElement.style.visibility = ''; + this.parentElement.removeAttribute('data-paper-shader-placeholder'); }; /** Creates a texture from an image and sets it into a uniform value */ @@ -645,8 +633,7 @@ export class ShaderMount { this.uniformLocations = {}; // Clean up placeholder if present - this.placeholderElement?.remove(); - this.placeholderElement = null; + this.parentElement.removeAttribute('data-paper-shader-placeholder'); // Remove the shader from the div wrapper element this.canvasElement.remove(); @@ -733,7 +720,8 @@ const defaultStyle = `@layer paper-shaders { corner-shape: inherit; } - & [data-paper-shader-placeholder] { + &[data-paper-shader-placeholder]::after { + content: 'WebGL context limit reached'; display: flex; align-items: center; justify-content: center; @@ -750,6 +738,10 @@ const defaultStyle = `@layer paper-shaders { outline: 1px solid rgba(255, 255, 255, 0.1); outline-offset: -1px; transition: opacity 150ms ease; + + @starting-style { + opacity: 0; + } } } }`; From b4b1e44e113ccb4ae86c262e00bc2bca7b3408c1 Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 13:55:49 +0100 Subject: [PATCH 5/9] fix the context restore, don't use webglcontextrestored --- packages/shaders/src/shader-mount.ts | 70 ++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index d7055055..3dc6aedb 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -36,6 +36,10 @@ export class ShaderMount { private uniformCache: Record = {}; private textureUnitMap: Map = new Map(); private contextIsLost = false; + private webGlContextAttributes: WebGLContextAttributes | undefined; + + /** Mounts waiting to be restored when a context slot frees up */ + private static waitingForRestore: Set = new Set(); constructor( /** The div you'd like to mount the shader to. The shader will match its size. */ @@ -87,6 +91,7 @@ export class ShaderMount { this.currentFrame = frame; this.minPixelRatio = minPixelRatio; this.maxPixelCount = maxPixelCount; + this.webGlContextAttributes = webGlContextAttributes; const gl = canvasElement.getContext('webgl2', webGlContextAttributes); if (!gl) { @@ -119,7 +124,6 @@ export class ShaderMount { // Handle WebGL context loss (browsers silently evict contexts when too many are active) this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); - this.canvasElement.addEventListener('webglcontextrestored', this.handleContextRestored); } @@ -341,13 +345,39 @@ export class ShaderMount { } this.showPlaceholder(); + ShaderMount.waitingForRestore.add(this); + }; + + private showPlaceholder = (): void => { + this.canvasElement.style.visibility = 'hidden'; + this.parentElement.setAttribute('data-paper-shader-placeholder', ''); + }; + + private hidePlaceholder = (): void => { + this.canvasElement.style.visibility = ''; + this.parentElement.removeAttribute('data-paper-shader-placeholder'); }; - private handleContextRestored = (): void => { + /** Try to restore this mount by creating a fresh canvas + context */ + private tryRestore = (): boolean => { + const newCanvas = document.createElement('canvas'); + const gl = newCanvas.getContext('webgl2', this.webGlContextAttributes); + if (!gl) return false; + + // Remove listeners from old canvas + this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); + + // Swap canvases in DOM + this.canvasElement.replaceWith(newCanvas); + this.canvasElement = newCanvas; + this.gl = gl; + + // Add listeners to new canvas + this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); + + // Re-init all GL state this.contextIsLost = false; this.hidePlaceholder(); - - // Re-init all GL state on the restored context this.initProgram(); this.setupPositionAttribute(); this.setupUniforms(); @@ -356,24 +386,27 @@ export class ShaderMount { this.textures.clear(); this.setUniformValues(this.providedUniforms); this.resolutionChanged = true; - this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + + // Re-setup resize observer for the new canvas + this.resizeObserver?.disconnect(); + this.setupResizeObserver(); this.lastRenderTime = performance.now(); this.render(performance.now()); if (this.currentSpeed !== 0) { this.requestRender(); } - }; - private showPlaceholder = (): void => { - this.canvasElement.style.visibility = 'hidden'; - this.parentElement.setAttribute('data-paper-shader-placeholder', ''); + ShaderMount.waitingForRestore.delete(this); + return true; }; - private hidePlaceholder = (): void => { - this.canvasElement.style.visibility = ''; - this.parentElement.removeAttribute('data-paper-shader-placeholder'); - }; + /** Try to restore waiting mounts after a context slot was freed */ + private static tryRestoreWaiting(): void { + for (const mount of [...ShaderMount.waitingForRestore]) { + if (!mount.tryRestore()) break; + } + } /** Creates a texture from an image and sets it into a uniform value */ private setTextureUniform = (uniformName: string, image: HTMLImageElement): void => { @@ -600,6 +633,9 @@ export class ShaderMount { this.rafId = null; } + // Remove from waiting set if queued for restore + ShaderMount.waitingForRestore.delete(this); + if (this.gl && this.program) { // Clean up all textures this.textures.forEach((texture) => { @@ -618,6 +654,10 @@ export class ShaderMount { // Clear any errors this.gl.getError(); + + // Explicitly release the WebGL context so the browser frees the slot immediately + const loseExt = this.gl.getExtension('WEBGL_lose_context'); + if (loseExt) loseExt.loseContext(); } if (this.resizeObserver) { @@ -628,7 +668,6 @@ export class ShaderMount { visualViewport?.removeEventListener('resize', this.handleVisualViewportChange); document.removeEventListener('visibilitychange', this.handleDocumentVisibilityChange); this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); - this.canvasElement.removeEventListener('webglcontextrestored', this.handleContextRestored); this.uniformLocations = {}; @@ -639,6 +678,9 @@ export class ShaderMount { this.canvasElement.remove(); // Free up the reference to self to enable garbage collection delete this.parentElement.paperShaderMount; + + // Try to restore mounts that lost their context now that a slot freed up + ShaderMount.tryRestoreWaiting(); }; } From b0aa7b04a9938ce1d178b6a9fbad7275b4584fd4 Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 14:08:08 +0100 Subject: [PATCH 6/9] remove restore logic --- packages/shaders/src/shader-mount.ts | 66 ---------------------------- 1 file changed, 66 deletions(-) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index 3dc6aedb..01960b1d 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -36,10 +36,6 @@ export class ShaderMount { private uniformCache: Record = {}; private textureUnitMap: Map = new Map(); private contextIsLost = false; - private webGlContextAttributes: WebGLContextAttributes | undefined; - - /** Mounts waiting to be restored when a context slot frees up */ - private static waitingForRestore: Set = new Set(); constructor( /** The div you'd like to mount the shader to. The shader will match its size. */ @@ -91,7 +87,6 @@ export class ShaderMount { this.currentFrame = frame; this.minPixelRatio = minPixelRatio; this.maxPixelCount = maxPixelCount; - this.webGlContextAttributes = webGlContextAttributes; const gl = canvasElement.getContext('webgl2', webGlContextAttributes); if (!gl) { @@ -345,7 +340,6 @@ export class ShaderMount { } this.showPlaceholder(); - ShaderMount.waitingForRestore.add(this); }; private showPlaceholder = (): void => { @@ -358,56 +352,6 @@ export class ShaderMount { this.parentElement.removeAttribute('data-paper-shader-placeholder'); }; - /** Try to restore this mount by creating a fresh canvas + context */ - private tryRestore = (): boolean => { - const newCanvas = document.createElement('canvas'); - const gl = newCanvas.getContext('webgl2', this.webGlContextAttributes); - if (!gl) return false; - - // Remove listeners from old canvas - this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); - - // Swap canvases in DOM - this.canvasElement.replaceWith(newCanvas); - this.canvasElement = newCanvas; - this.gl = gl; - - // Add listeners to new canvas - this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); - - // Re-init all GL state - this.contextIsLost = false; - this.hidePlaceholder(); - this.initProgram(); - this.setupPositionAttribute(); - this.setupUniforms(); - this.uniformCache = {}; - this.textureUnitMap.clear(); - this.textures.clear(); - this.setUniformValues(this.providedUniforms); - this.resolutionChanged = true; - - // Re-setup resize observer for the new canvas - this.resizeObserver?.disconnect(); - this.setupResizeObserver(); - - this.lastRenderTime = performance.now(); - this.render(performance.now()); - if (this.currentSpeed !== 0) { - this.requestRender(); - } - - ShaderMount.waitingForRestore.delete(this); - return true; - }; - - /** Try to restore waiting mounts after a context slot was freed */ - private static tryRestoreWaiting(): void { - for (const mount of [...ShaderMount.waitingForRestore]) { - if (!mount.tryRestore()) break; - } - } - /** Creates a texture from an image and sets it into a uniform value */ private setTextureUniform = (uniformName: string, image: HTMLImageElement): void => { if (!image.complete || image.naturalWidth === 0) { @@ -633,9 +577,6 @@ export class ShaderMount { this.rafId = null; } - // Remove from waiting set if queued for restore - ShaderMount.waitingForRestore.delete(this); - if (this.gl && this.program) { // Clean up all textures this.textures.forEach((texture) => { @@ -654,10 +595,6 @@ export class ShaderMount { // Clear any errors this.gl.getError(); - - // Explicitly release the WebGL context so the browser frees the slot immediately - const loseExt = this.gl.getExtension('WEBGL_lose_context'); - if (loseExt) loseExt.loseContext(); } if (this.resizeObserver) { @@ -678,9 +615,6 @@ export class ShaderMount { this.canvasElement.remove(); // Free up the reference to self to enable garbage collection delete this.parentElement.paperShaderMount; - - // Try to restore mounts that lost their context now that a slot freed up - ShaderMount.tryRestoreWaiting(); }; } From a642d092f76ef0bae1c6a7cf290deabf16d25942 Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 15:20:15 +0100 Subject: [PATCH 7/9] reset shadermount to original stage --- packages/shaders/src/shader-mount.ts | 63 ---------------------------- 1 file changed, 63 deletions(-) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index 01960b1d..b327d682 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -35,7 +35,6 @@ export class ShaderMount { private isSafari = isSafari(); private uniformCache: Record = {}; private textureUnitMap: Map = new Map(); - private contextIsLost = false; constructor( /** The div you'd like to mount the shader to. The shader will match its size. */ @@ -116,10 +115,6 @@ export class ShaderMount { // Listen for document visibility changes to pause the shader when the tab is hidden document.addEventListener('visibilitychange', this.handleDocumentVisibilityChange); - - // Handle WebGL context loss (browsers silently evict contexts when too many are active) - this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); - } private initProgram = () => { @@ -275,14 +270,6 @@ export class ShaderMount { private render = (currentTime: number) => { if (this.hasBeenDisposed) return; - if (this.contextIsLost) return; - - // Detect context loss before the async event fires, so we never show a blank canvas - if (this.gl.isContextLost()) { - this.contextIsLost = true; - this.showPlaceholder(); - return; - } if (this.program === null) { console.warn('Tried to render before program or gl was initialized'); @@ -330,28 +317,6 @@ export class ShaderMount { this.rafId = requestAnimationFrame(this.render); }; - private handleContextLost = (e: Event): void => { - e.preventDefault(); - this.contextIsLost = true; - - if (this.rafId !== null) { - cancelAnimationFrame(this.rafId); - this.rafId = null; - } - - this.showPlaceholder(); - }; - - private showPlaceholder = (): void => { - this.canvasElement.style.visibility = 'hidden'; - this.parentElement.setAttribute('data-paper-shader-placeholder', ''); - }; - - private hidePlaceholder = (): void => { - this.canvasElement.style.visibility = ''; - this.parentElement.removeAttribute('data-paper-shader-placeholder'); - }; - /** Creates a texture from an image and sets it into a uniform value */ private setTextureUniform = (uniformName: string, image: HTMLImageElement): void => { if (!image.complete || image.naturalWidth === 0) { @@ -604,13 +569,9 @@ export class ShaderMount { visualViewport?.removeEventListener('resize', this.handleVisualViewportChange); document.removeEventListener('visibilitychange', this.handleDocumentVisibilityChange); - this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); this.uniformLocations = {}; - // Clean up placeholder if present - this.parentElement.removeAttribute('data-paper-shader-placeholder'); - // Remove the shader from the div wrapper element this.canvasElement.remove(); // Free up the reference to self to enable garbage collection @@ -695,30 +656,6 @@ const defaultStyle = `@layer paper-shaders { border-radius: inherit; corner-shape: inherit; } - - &[data-paper-shader-placeholder]::after { - content: 'WebGL context limit reached'; - display: flex; - align-items: center; - justify-content: center; - position: absolute; - inset: 0; - z-index: -1; - width: 100%; - height: 100%; - border-radius: inherit; - corner-shape: inherit; - background: rgba(0, 0, 0, 0.5); - color: rgba(255, 255, 255, 0.5); - font: 13px/1 system-ui, sans-serif; - outline: 1px solid rgba(255, 255, 255, 0.1); - outline-offset: -1px; - transition: opacity 150ms ease; - - @starting-style { - opacity: 0; - } - } } }`; From e705385b53d55799b00b0d89877848f156e5f74c Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 15:28:29 +0100 Subject: [PATCH 8/9] add placeholder over canvas with lost context --- packages/shaders/src/shader-mount.ts | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index b327d682..dc8c6a53 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -115,6 +115,9 @@ export class ShaderMount { // Listen for document visibility changes to pause the shader when the tab is hidden document.addEventListener('visibilitychange', this.handleDocumentVisibilityChange); + + // Handle WebGL context loss (browsers silently evict contexts when too many are active) + this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); } private initProgram = () => { @@ -317,6 +320,18 @@ export class ShaderMount { this.rafId = requestAnimationFrame(this.render); }; + private handleContextLost = (e: Event): void => { + e.preventDefault(); + + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + + this.canvasElement.style.visibility = 'hidden'; + this.parentElement.setAttribute('data-paper-shader-placeholder', ''); + }; + /** Creates a texture from an image and sets it into a uniform value */ private setTextureUniform = (uniformName: string, image: HTMLImageElement): void => { if (!image.complete || image.naturalWidth === 0) { @@ -569,8 +584,10 @@ export class ShaderMount { visualViewport?.removeEventListener('resize', this.handleVisualViewportChange); document.removeEventListener('visibilitychange', this.handleDocumentVisibilityChange); + this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); this.uniformLocations = {}; + this.parentElement.removeAttribute('data-paper-shader-placeholder'); // Remove the shader from the div wrapper element this.canvasElement.remove(); @@ -656,6 +673,20 @@ const defaultStyle = `@layer paper-shaders { border-radius: inherit; corner-shape: inherit; } + + &[data-paper-shader-placeholder]::after { + content: 'WebGL context limit reached'; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + corner-shape: inherit; + background: rgba(0, 0, 0, 0.3); + color: rgba(255, 255, 255, 0.8); + } } }`; From ad71a560d0c132f3bbed952821de87a376a418ce Mon Sep 17 00:00:00 2001 From: Ksenia Kondrashova Date: Thu, 26 Feb 2026 16:02:59 +0100 Subject: [PATCH 9/9] minimal handleContextRestored --- packages/shaders/src/shader-mount.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/shaders/src/shader-mount.ts b/packages/shaders/src/shader-mount.ts index dc8c6a53..4ca28e0a 100644 --- a/packages/shaders/src/shader-mount.ts +++ b/packages/shaders/src/shader-mount.ts @@ -118,6 +118,7 @@ export class ShaderMount { // Handle WebGL context loss (browsers silently evict contexts when too many are active) this.canvasElement.addEventListener('webglcontextlost', this.handleContextLost); + this.canvasElement.addEventListener('webglcontextrestored', this.handleContextRestored); } private initProgram = () => { @@ -332,6 +333,27 @@ export class ShaderMount { this.parentElement.setAttribute('data-paper-shader-placeholder', ''); }; + private handleContextRestored = (): void => { + this.canvasElement.style.visibility = ''; + this.parentElement.removeAttribute('data-paper-shader-placeholder'); + + this.initProgram(); + this.setupPositionAttribute(); + this.setupUniforms(); + this.uniformCache = {}; + this.textureUnitMap.clear(); + this.textures.clear(); + this.setUniformValues(this.providedUniforms); + this.resolutionChanged = true; + this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height); + + this.lastRenderTime = performance.now(); + this.render(performance.now()); + if (this.currentSpeed !== 0) { + this.requestRender(); + } + }; + /** Creates a texture from an image and sets it into a uniform value */ private setTextureUniform = (uniformName: string, image: HTMLImageElement): void => { if (!image.complete || image.naturalWidth === 0) { @@ -585,6 +607,7 @@ export class ShaderMount { visualViewport?.removeEventListener('resize', this.handleVisualViewportChange); document.removeEventListener('visibilitychange', this.handleDocumentVisibilityChange); this.canvasElement.removeEventListener('webglcontextlost', this.handleContextLost); + this.canvasElement.removeEventListener('webglcontextrestored', this.handleContextRestored); this.uniformLocations = {}; this.parentElement.removeAttribute('data-paper-shader-placeholder');