perf(Grainient): split useEffect, pause RAF when offscreen/tab hidden#908
perf(Grainient): split useEffect, pause RAF when offscreen/tab hidden#908mohamed-younes16 wants to merge 1 commit intoDavidHDev:mainfrom
Conversation
Problems in the original:
- Single useEffect with all 22 props as dependencies caused a full WebGL
context teardown and canvas remount on every prop change — GPU pipeline
rebuilt from scratch for something as minor as a color tweak.
- requestAnimationFrame ran unconditionally at 60fps even when the element
was scrolled completely offscreen, burning GPU cycles with no visible output.
- No awareness of browser tab visibility — shader kept executing even in
background tabs.
Changes (applied to all 4 variants: JS-CSS, JS-TW, TS-CSS, TS-TW):
1. Split into two useEffects:
- Effect 1 ([] deps): creates renderer, canvas, geometry, program, mesh
exactly once for the lifetime of the component.
- Effect 2 (prop deps): writes directly to uniform values — zero GPU cost,
no context recreation, no canvas remount.
2. WeakMap<HTMLDivElement, GrainientCtx> bridges the two effects without
creating strong references that would leak on unmount.
3. IntersectionObserver (threshold: 0) pauses the RAF loop the moment the
canvas scrolls offscreen and resumes when it re-enters the viewport.
4. visibilitychange listener pauses the RAF loop when the browser tab is
hidden and resumes when the user returns to it.
Result: no unnecessary GPU work, dramatically lower CPU/GPU usage on pages
where the component is not in view, and instant prop updates without flicker.
There was a problem hiding this comment.
Pull request overview
Improves the Grainient background component’s runtime performance by avoiding WebGL reinitialization on prop changes and reducing unnecessary GPU work when the component is not visible.
Changes:
- Split WebGL initialization (one-time) from uniform updates (prop-driven) to prevent context teardown/rebuild on prop updates.
- Pause/resume the RAF render loop based on viewport intersection and page/tab visibility.
- Updated all 4 published variants plus their registry JSON snapshots.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/ts-tailwind/Backgrounds/Grainient/Grainient.tsx | TS + Tailwind variant: one-time WebGL setup, prop-to-uniform sync effect, visibility-based RAF pausing. |
| src/ts-default/Backgrounds/Grainient/Grainient.tsx | TS + CSS variant: same lifecycle split and RAF pausing behavior. |
| src/tailwind/Backgrounds/Grainient/Grainient.jsx | JS + Tailwind variant: same lifecycle split and RAF pausing behavior. |
| src/content/Backgrounds/Grainient/Grainient.jsx | JS + CSS/content variant: same lifecycle split and RAF pausing behavior. |
| public/r/Grainient-TS-TW.json | Registry snapshot updated to reflect TS+TW implementation changes. |
| public/r/Grainient-TS-CSS.json | Registry snapshot updated to reflect TS+CSS implementation changes. |
| public/r/Grainient-JS-TW.json | Registry snapshot updated to reflect JS+TW implementation changes. |
| public/r/Grainient-JS-CSS.json | Registry snapshot updated to reflect JS+CSS implementation changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| u.uCenterOffset.value = new Float32Array([centerX, centerY]); | ||
| u.uZoom.value = zoom; | ||
| u.uColor1.value = new Float32Array(hexToRgb(color1)); | ||
| u.uColor2.value = new Float32Array(hexToRgb(color2)); | ||
| u.uColor3.value = new Float32Array(hexToRgb(color3)); |
| uTimeSpeed: { value: 0.25 }, | ||
| uColorBalance: { value: 0.0 }, | ||
| uWarpStrength: { value: 1.0 }, | ||
| uWarpFrequency: { value: 5.0 }, | ||
| uWarpSpeed: { value: 2.0 }, |
| u.uCenterOffset.value = new Float32Array([centerX, centerY]); | ||
| u.uZoom.value = zoom; | ||
| u.uColor1.value = new Float32Array(hexToRgb(color1)); | ||
| u.uColor2.value = new Float32Array(hexToRgb(color2)); | ||
| u.uColor3.value = new Float32Array(hexToRgb(color3)); |
| uTimeSpeed: { value: 0.25 }, | ||
| uColorBalance: { value: 0.0 }, | ||
| uWarpStrength: { value: 1.0 }, | ||
| uWarpFrequency: { value: 5.0 }, | ||
| uWarpSpeed: { value: 2.0 }, | ||
| uWarpAmplitude: { value: 50.0 }, | ||
| uBlendAngle: { value: 0.0 }, | ||
| uBlendSoftness: { value: 0.05 }, | ||
| uRotationAmount: { value: 500.0 }, | ||
| uNoiseScale: { value: 2.0 }, | ||
| uGrainAmount: { value: 0.1 }, | ||
| uGrainScale: { value: 2.0 }, | ||
| uGrainAnimated: { value: 0.0 }, | ||
| uContrast: { value: 1.5 }, | ||
| uGamma: { value: 1.0 }, | ||
| uSaturation: { value: 1.0 }, | ||
| uCenterOffset: { value: new Float32Array([0, 0]) }, | ||
| uZoom: { value: 0.9 }, | ||
| uColor1: { value: new Float32Array([1, 1, 1]) }, | ||
| uColor2: { value: new Float32Array([1, 1, 1]) }, | ||
| uColor3: { value: new Float32Array([1, 1, 1]) } |
| u.uCenterOffset.value = new Float32Array([centerX, centerY]); | ||
| u.uZoom.value = zoom; | ||
| u.uColor1.value = new Float32Array(hexToRgb(color1)); | ||
| u.uColor2.value = new Float32Array(hexToRgb(color2)); | ||
| u.uColor3.value = new Float32Array(hexToRgb(color3)); |
| uTimeSpeed: { value: 0.25 }, | ||
| uColorBalance: { value: 0.0 }, | ||
| uWarpStrength: { value: 1.0 }, | ||
| uWarpFrequency: { value: 5.0 }, | ||
| uWarpSpeed: { value: 2.0 }, |
| u.uCenterOffset.value = new Float32Array([centerX, centerY]); | ||
| u.uZoom.value = zoom; | ||
| u.uColor1.value = new Float32Array(hexToRgb(color1)); | ||
| u.uColor2.value = new Float32Array(hexToRgb(color2)); | ||
| u.uColor3.value = new Float32Array(hexToRgb(color3)); |
| uColorBalance: { value: 0.0 }, | ||
| uWarpStrength: { value: 1.0 }, | ||
| uWarpFrequency: { value: 5.0 }, | ||
| uWarpSpeed: { value: 2.0 }, |
|
Thanks for the Copilot review. Main point for this PR: it fixes the largest runtime costs in
These are the high-impact performance issues and the primary reason for this PR. I also reviewed the Copilot follow-up notes (initial uniform values and in-place Float32Array updates). Those are valid micro-optimizations, but comparatively lower impact than the architectural fixes above. Happy to push a follow-up commit for those refinements as well if you’d like. |
perf(Grainient): eliminate WebGL teardown on prop changes + pause RAF when offscreen
Problem
Two performance issues discovered during real-world usage:
1. Full WebGL context teardown on every prop change
The entire component lived inside a single
useEffectwith all 22 props in thedependency array. Changing any prop triggered:
cancelAnimationFrame→ RAF stoppedcontainer.removeChild(canvas)→ canvas torn out of the DOMnew Renderer(...)→ fresh WebGL context allocated on the GPUnew Program(...)→ shader recompiled and re-uploadedA full GPU pipeline rebuild for something as minor as tweaking a color value.
This is most noticeable in the interactive demo — every slider drag caused a
visible flicker and a full context reset.
2. RAF loop ran unconditionally — even when completely offscreen
requestAnimationFramefired 60 times/sec regardless of visibility. Thefragment shader (running
noise(),sin(),mix(),pow()on every pixel,every frame) kept burning GPU cycles even when scrolled off screen or in a
background tab. In practice: audible fan spin-up when Grainient is used as a
section background on a scrollable page.
Fix
Split into two
useEffects:[]deps) — creates renderer, canvas, program, mesh exactly once for the component lifetime.valueproperties. Zero GPU cost, no teardown, no flickerA
WeakMap<HTMLDivElement, GrainientCtx>bridges the two effects without strong references that would leak on unmount.Pause RAF when not needed:
IntersectionObserver(threshold: 0) — stops the loop the moment the canvas leaves the viewport, resumes when it re-entersvisibilitychange— stops the loop when the tab is hidden, resumes when the user returnsResult
The interactive demo now updates every parameter (colors, warp, grain, etc.) instantly and completely flicker-free — no context reset, no canvas remount, just a uniform value write. GPU usage drops to zero when the component is offscreen.