From 18d48d9a4fd40b28dc21297bc841ffb6ca04c299 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 30 Jul 2026 17:58:17 +0530 Subject: [PATCH 1/2] fix(theme): cancel mesh-gradient timers on teardown (#5160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Gradient` owns three async chains that outlive the canvas React gives it: the `waitForCssVars` rAF retry loop (up to 200 frames), the `animate` rAF loop, and the 3s deferred `isLoaded` class. `disconnect()` cancelled none of them, so every `` unmount — a theme switch, a backdrop change, window teardown — left callbacks queued against a canvas React had already removed. The `isLoaded` timeout then read `this.el.parentElement.classList` on a detached node, which is the "Cannot read properties of null (reading 'classList')" family Sentry grouped across five bundle hashes (27 events / 13 users). PR #5171 guarded the expression; this removes the reason it was ever reached. Each async entry point now records its handle and bails on `destroyed`: - `disconnect()` latches `destroyed`, clears the `isLoaded` timeout and the scroll-end debounce, and cancels both rAF handles. Idempotent. - `init()` / `waitForCssVars()` / `play()` / `animate()` return early once disconnected, so a frame already dispatched in the current tick cannot restart the retry chain or dereference the absent `mesh`. - `pause()` cancels the queued frame (which its documented contract in `MeshGradient.tsx` already claimed) and no longer assumes `conf` exists — the wrapper calls it right after `disconnect()` on gradients whose WebGL init bailed early. - `resize()` ignores events that arrive without a live `minigl`/`mesh`. Regression tests in `app/src/lib/meshGradient.test.ts` drive the lifecycle directly (no WebGL): five of the nine fail against the previous implementation. Teardown decisions are logged under `[MeshGradient]`. --- app/src/lib/meshGradient.d.ts | 17 +++ app/src/lib/meshGradient.js | 114 ++++++++++++++++--- app/src/lib/meshGradient.test.ts | 181 +++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 app/src/lib/meshGradient.test.ts diff --git a/app/src/lib/meshGradient.d.ts b/app/src/lib/meshGradient.d.ts index 6b6621fb18..476f5af96d 100644 --- a/app/src/lib/meshGradient.d.ts +++ b/app/src/lib/meshGradient.d.ts @@ -12,10 +12,27 @@ export class Gradient { * `mesh.material` and would throw when it is absent (#3524). */ mesh?: unknown; + /** Resolved style of the canvas; only set once `connect()` ran. */ + computedCanvasStyle?: CSSStyleDeclaration; + /** + * Latched by `disconnect()`. Every async entry point below bails on it so a + * queued frame/timer never touches a canvas React already unmounted (#5160). + */ + destroyed: boolean; play(): void; pause(): void; disconnect(): void; initGradient(selector: string): this; toggleColor(index: number): void; updateFrequency(freq: number): void; + /** + * Lifecycle internals. Public only because the teardown regression tests + * (#5160) drive them directly — the React wrapper uses `initGradient`, + * `play`, `pause` and `disconnect`. + */ + init(): void; + initMesh(): void; + resize(): void; + waitForCssVars(): void; + addIsLoadedClass(): void; } diff --git a/app/src/lib/meshGradient.js b/app/src/lib/meshGradient.js index b7fcf36498..18d8d99102 100644 --- a/app/src/lib/meshGradient.js +++ b/app/src/lib/meshGradient.js @@ -400,6 +400,21 @@ class Gradient { e(this, 'maxCssVarRetries', 200), e(this, 'angle', 0), e(this, 'isLoadedClass', !1), + /* + * Teardown bookkeeping (#5160). A Gradient owns three async chains that + * outlive the canvas: the `waitForCssVars` rAF retry loop (up to 200 + * frames), the `animate` rAF loop, and the 3s `isLoaded` timeout. React + * unmounts `` on every theme switch / backdrop change, so + * those callbacks used to run against a canvas that was already detached + * and dereference `this.el.parentElement.classList` — the source of the + * "Cannot read properties of null (reading 'classList')" family. Every + * async entry point now records its handle here and bails on `destroyed`, + * and `disconnect()` cancels all of them. + */ + e(this, 'destroyed', !1), + e(this, 'isLoadedTimeout', void 0), + e(this, 'cssVarsRaf', 0), + e(this, 'animateRaf', 0), e(this, 'isScrolling', !1), /*e(this, "isStatic", o.disableAmbientAnimations()),*/ e(this, 'scrollingTimeout', void 0), e(this, 'scrollingRefreshDelay', 200), @@ -441,6 +456,10 @@ class Gradient { ((this.isScrolling = !1), this.isIntersecting && this.play()); }), e(this, 'resize', () => { + if (this.destroyed || !this.minigl || !this.mesh) { + console.debug('[MeshGradient] resize ignored — gradient not live'); + return; + } ((this.width = window.innerWidth), (this.height = window.innerHeight), this.minigl.setSize(this.width, this.height), @@ -455,12 +474,17 @@ class Gradient { this.isGradientLegendVisible && ((this.isMetaKey = e.metaKey), (this.isMouseDown = !0), - !1 === this.conf.playing && requestAnimationFrame(this.animate)); + !1 === this.conf.playing && (this.animateRaf = requestAnimationFrame(this.animate))); }), e(this, 'handleMouseUp', () => { this.isMouseDown = !1; }), e(this, 'animate', e => { + this.animateRaf = 0; + if (this.destroyed) { + console.debug('[MeshGradient] animate frame dropped — gradient disconnected'); + return; + } if (!this.shouldSkipFrame(e) || this.isMouseDown) { if (((this.t += Math.min(e - this.last, 1e3 / 15)), (this.last = e), this.isMouseDown)) { let e = 160; @@ -470,21 +494,44 @@ class Gradient { } if (0 !== this.last && this.isStatic) return (this.minigl.render(), void this.disconnect()); /*this.isIntersecting && */ (this.conf.playing || this.isMouseDown) && - requestAnimationFrame(this.animate); + (this.animateRaf = requestAnimationFrame(this.animate)); }), e(this, 'addIsLoadedClass', () => { - /*this.isIntersecting && */ !this.isLoadedClass && - ((this.isLoadedClass = !0), + /*this.isIntersecting && */ + if (this.destroyed || this.isLoadedClass) return; + ((this.isLoadedClass = !0), this.el && this.el.classList.add('isLoaded'), - setTimeout(() => { - this.el && this.el.parentElement && this.el.parentElement.classList.add('isLoaded'); - }, 3e3)); + // Deferred so the canvas has faded in before the wrapper is marked + // loaded. The handle is retained because React can unmount the canvas + // well inside these 3s (#5160) — `disconnect()` clears it, and the + // detached-node checks below cover a teardown we did not observe. + (this.isLoadedTimeout = setTimeout(() => { + this.isLoadedTimeout = void 0; + const parent = this.destroyed ? null : this.el && this.el.parentElement; + if (!parent) { + console.debug('[MeshGradient] isLoaded skipped — canvas detached before timeout', { + destroyed: this.destroyed, + hasEl: Boolean(this.el), + }); + return; + } + parent.classList.add('isLoaded'); + }, 3e3))); }), e(this, 'pause', () => { - this.conf.playing = false; + // `conf` only exists once connect() ran, and the React wrapper calls + // pause() right after disconnect() on a gradient whose WebGL init may + // have bailed early — so never assume it is there. + (this.conf && (this.conf.playing = false), + this.animateRaf && cancelAnimationFrame(this.animateRaf), + (this.animateRaf = 0)); }), e(this, 'play', () => { - (requestAnimationFrame(this.animate), (this.conf.playing = true)); + if (this.destroyed) { + console.debug('[MeshGradient] play ignored — gradient disconnected'); + return; + } + ((this.animateRaf = requestAnimationFrame(this.animate)), (this.conf.playing = true)); }), e(this, 'initGradient', selector => { this.el = document.querySelector(selector); @@ -519,10 +566,13 @@ class Gradient { ? console.log('DID NOT LOAD HERO STRIPE CANVAS') : ((this.minigl = new MiniGl(this.el, null, null, !0)), this.minigl.gl - ? requestAnimationFrame(() => { - this.el && - ((this.computedCanvasStyle = getComputedStyle(this.el)), this.waitForCssVars()); - }) + ? (this.cssVarsRaf = requestAnimationFrame(() => { + ((this.cssVarsRaf = 0), + !this.destroyed && + this.el && + ((this.computedCanvasStyle = getComputedStyle(this.el)), + this.waitForCssVars())); + })) : console.warn('[MeshGradient] MiniGl has no GL context — gradient disabled'))); /* this.scrollObserver = await s.create(.1, !1), @@ -534,7 +584,32 @@ class Gradient { window.addEventListener("scroll", this.handleScroll), window.addEventListener("mousedown", this.handleMouseDown), window.addEventListener("mouseup", this.handleMouseUp), window.addEventListener("keydown", this.handleKeyDown), this.isIntersecting = !0, this.addIsLoadedClass(), this.play() })*/ } + /* + * Tears the gradient down for good. Cancels every pending async chain + * (`waitForCssVars` retries, the animation loop, the deferred `isLoaded` + * class, the scroll-end debounce) and latches `destroyed` so anything already + * queued in the current frame/task bails instead of touching a canvas React + * has removed from the document (#5160). Safe to call more than once. + */ disconnect() { + this.destroyed = !0; + if (this.conf) this.conf.playing = !1; + if (this.isLoadedTimeout) { + clearTimeout(this.isLoadedTimeout); + this.isLoadedTimeout = void 0; + } + if (this.scrollingTimeout) { + clearTimeout(this.scrollingTimeout); + this.scrollingTimeout = void 0; + } + if (this.cssVarsRaf) { + cancelAnimationFrame(this.cssVarsRaf); + this.cssVarsRaf = 0; + } + if (this.animateRaf) { + cancelAnimationFrame(this.animateRaf); + this.animateRaf = 0; + } (this.scrollObserver && (window.removeEventListener('scroll', this.handleScroll), window.removeEventListener('mousedown', this.handleMouseDown), @@ -542,6 +617,7 @@ class Gradient { window.removeEventListener('keydown', this.handleKeyDown), this.scrollObserver.disconnect()), window.removeEventListener('resize', this.resize)); + console.debug('[MeshGradient] disconnected — pending frames and timers cancelled'); } initMaterial() { this.uniforms = { @@ -631,6 +707,10 @@ class Gradient { document.body && document.body.classList.remove('isGradientLegendVisible')); } init() { + if (this.destroyed) { + console.debug('[MeshGradient] init skipped — gradient already disconnected'); + return; + } try { (this.initGradientColors(), this.initMesh(), @@ -646,6 +726,10 @@ class Gradient { * Using default colors assigned below if no variables have been found after maxCssVarRetries */ waitForCssVars() { + if (this.destroyed) { + console.debug('[MeshGradient] waitForCssVars stopped — gradient disconnected'); + return; + } if ( this.computedCanvasStyle && -1 !== this.computedCanvasStyle.getPropertyValue('--gradient-color-1').indexOf('#') @@ -658,7 +742,9 @@ class Gradient { void this.init() ); } - requestAnimationFrame(() => this.waitForCssVars()); + this.cssVarsRaf = requestAnimationFrame(() => { + ((this.cssVarsRaf = 0), this.waitForCssVars()); + }); } } /* diff --git a/app/src/lib/meshGradient.test.ts b/app/src/lib/meshGradient.test.ts new file mode 100644 index 0000000000..dcf02e4fe2 --- /dev/null +++ b/app/src/lib/meshGradient.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Gradient } from './meshGradient'; + +/** + * Teardown safety for the WebGL mesh gradient (issue #5160). + * + * `Gradient` drives three async chains that all outlive a single React commit: + * the `waitForCssVars` rAF retry loop, the `animate` rAF loop, and the 3s + * deferred `isLoaded` class. `` unmounts on every theme switch + * and backdrop change, so those callbacks used to fire against a canvas React + * had already removed — `this.el.parentElement` was `null` and the + * `.classList.add()` threw the "Cannot read properties of null (reading + * 'classList')" family Sentry grouped across five bundles. + * + * These tests drive the lib directly (no WebGL): they set `el` by hand and + * exercise the lifecycle entry points, which is where the leak lived. + */ +describe('Gradient teardown (#5160)', () => { + let wrapper: HTMLDivElement; + let canvas: HTMLCanvasElement; + let rafQueue: Map; + let nextRafHandle: number; + let cancelled: number[]; + + beforeEach(() => { + vi.useFakeTimers(); + wrapper = document.createElement('div'); + canvas = document.createElement('canvas'); + canvas.id = 'mesh-gradient'; + wrapper.appendChild(canvas); + document.body.appendChild(wrapper); + + rafQueue = new Map(); + nextRafHandle = 0; + cancelled = []; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { + nextRafHandle += 1; + rafQueue.set(nextRafHandle, callback); + return nextRafHandle; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(handle => { + cancelled.push(handle); + rafQueue.delete(handle); + }); + }); + + afterEach(() => { + wrapper.remove(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + /** Runs whatever is queued right now, exactly like a single browser frame. */ + function flushFrame() { + const pending = [...rafQueue.entries()]; + rafQueue.clear(); + for (const [, callback] of pending) callback(performance.now()); + } + + function mountedGradient() { + const gradient = new Gradient(); + gradient.el = canvas; + return gradient; + } + + it('marks the wrapper loaded when the canvas is still mounted 3s later', () => { + const gradient = mountedGradient(); + + gradient.addIsLoadedClass(); + expect(canvas.classList.contains('isLoaded')).toBe(true); + + vi.advanceTimersByTime(3000); + expect(wrapper.classList.contains('isLoaded')).toBe(true); + }); + + it('cancels the deferred isLoaded class when disconnect() runs first', () => { + const gradient = mountedGradient(); + + gradient.addIsLoadedClass(); + // React unmounts the component well inside the 3s window. The canvas is + // deliberately left in the DOM here so the assertion pins the *timer being + // cancelled* rather than the detached-node guard below it. + gradient.disconnect(); + + expect(() => vi.advanceTimersByTime(3000)).not.toThrow(); + expect(wrapper.classList.contains('isLoaded')).toBe(false); + }); + + it('skips the deferred isLoaded class when the canvas was detached without a disconnect', () => { + const gradient = mountedGradient(); + + gradient.addIsLoadedClass(); + // Node pulled out from under us — `el.parentElement` is now null, which is + // the exact dereference Sentry reported. + canvas.remove(); + + expect(() => vi.advanceTimersByTime(3000)).not.toThrow(); + expect(wrapper.classList.contains('isLoaded')).toBe(false); + }); + + it('stops the waitForCssVars retry loop after disconnect()', () => { + const gradient = mountedGradient(); + // No `--gradient-color-1` yet, so waitForCssVars re-schedules itself. + gradient.computedCanvasStyle = { getPropertyValue: () => '' } as unknown as CSSStyleDeclaration; + + gradient.waitForCssVars(); + expect(rafQueue.size).toBe(1); + + gradient.disconnect(); + expect(rafQueue.size).toBe(0); + + // Even a frame that was already dispatched must not restart the chain. + gradient.waitForCssVars(); + expect(rafQueue.size).toBe(0); + }); + + it('never re-enters init() once disconnected', () => { + const gradient = mountedGradient(); + const initMesh = vi.spyOn(gradient, 'initMesh'); + + gradient.disconnect(); + gradient.init(); + + expect(initMesh).not.toHaveBeenCalled(); + }); + + it('drops an animation frame that lands after disconnect() instead of throwing', () => { + const gradient = mountedGradient(); + gradient.conf = { playing: true }; + + gradient.play(); + expect(rafQueue.size).toBe(1); + const queuedFrame = [...rafQueue.values()][0]; + + gradient.disconnect(); + + // `mesh` never existed here, so the pre-fix animate() would have thrown on + // `this.mesh.material` for this already-queued frame. + expect(() => queuedFrame(performance.now())).not.toThrow(); + }); + + it('cancels the queued animation frame on pause() and refuses play() after disconnect()', () => { + const gradient = mountedGradient(); + const conf = { playing: false }; + gradient.conf = conf; + + gradient.play(); + expect(conf.playing).toBe(true); + const handle = nextRafHandle; + + gradient.pause(); + expect(cancelled).toContain(handle); + expect(conf.playing).toBe(false); + + gradient.disconnect(); + gradient.play(); + expect(rafQueue.size).toBe(0); + expect(conf.playing).toBe(false); + }); + + it('ignores a resize event fired after teardown', () => { + const gradient = mountedGradient(); + + gradient.disconnect(); + // `minigl`/`mesh` are absent, so the pre-fix handler threw here. + expect(() => gradient.resize()).not.toThrow(); + }); + + it('is safe to disconnect twice', () => { + const gradient = mountedGradient(); + + gradient.addIsLoadedClass(); + gradient.disconnect(); + expect(() => gradient.disconnect()).not.toThrow(); + + flushFrame(); + vi.advanceTimersByTime(3000); + expect(wrapper.classList.contains('isLoaded')).toBe(false); + }); +}); From 1f151cf69aabf027d322f0acfad8c3d51c12aad8 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 30 Jul 2026 19:02:48 +0530 Subject: [PATCH 2/2] fix(theme): track init()'s opening animation frame so disconnect() cancels it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `init()` scheduled its first `animate` frame without storing the handle, so `disconnect()` — which only cancels `this.animateRaf` — had nothing to cancel and that opening frame outlived teardown. Every other scheduling site (`animate`, `play`, the `!playing` branch) already assigns to `animateRaf`; this one was the outlier, and it is the same leak class this change set exists to close. Assign the handle and add a regression test that stubs the WebGL setup so `init()` reaches its `requestAnimationFrame`, then asserts `disconnect()` cancels that specific handle. Verified failing before the fix ("expected [] to include 1"). `initGradientColors` joins the lifecycle-internals block in `meshGradient.d.ts` so the test can stub it — same rationale as the `init`/`initMesh`/`resize` entries already there. Addresses the CodeRabbit review on #5273. --- app/src/lib/meshGradient.d.ts | 1 + app/src/lib/meshGradient.js | 5 ++++- app/src/lib/meshGradient.test.ts | 21 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/lib/meshGradient.d.ts b/app/src/lib/meshGradient.d.ts index 476f5af96d..bb013650d2 100644 --- a/app/src/lib/meshGradient.d.ts +++ b/app/src/lib/meshGradient.d.ts @@ -31,6 +31,7 @@ export class Gradient { * `play`, `pause` and `disconnect`. */ init(): void; + initGradientColors(): void; initMesh(): void; resize(): void; waitForCssVars(): void; diff --git a/app/src/lib/meshGradient.js b/app/src/lib/meshGradient.js index 18d8d99102..d0db6d1e2b 100644 --- a/app/src/lib/meshGradient.js +++ b/app/src/lib/meshGradient.js @@ -715,7 +715,10 @@ class Gradient { (this.initGradientColors(), this.initMesh(), this.resize(), - requestAnimationFrame(this.animate), + // Track this first frame like every other one: `disconnect()` only + // cancels `animateRaf`, so leaving this handle unstored let the opening + // frame outlive teardown — the exact leak this change set closes. + (this.animateRaf = requestAnimationFrame(this.animate)), window.addEventListener('resize', this.resize)); } catch (err) { console.warn('[MeshGradient] init failed, gradient disabled:', err); diff --git a/app/src/lib/meshGradient.test.ts b/app/src/lib/meshGradient.test.ts index dcf02e4fe2..a9808c39b2 100644 --- a/app/src/lib/meshGradient.test.ts +++ b/app/src/lib/meshGradient.test.ts @@ -125,6 +125,27 @@ describe('Gradient teardown (#5160)', () => { expect(initMesh).not.toHaveBeenCalled(); }); + it("cancels init()'s opening animation frame on disconnect()", () => { + const gradient = mountedGradient(); + // Stub the WebGL setup so init() reaches its requestAnimationFrame call + // without a real GL context. init() swallows throws, so the size assertion + // below is what proves the frame was actually scheduled. + vi.spyOn(gradient, 'initGradientColors').mockImplementation(() => {}); + vi.spyOn(gradient, 'initMesh').mockImplementation(() => {}); + vi.spyOn(gradient, 'resize').mockImplementation(() => {}); + + gradient.init(); + expect(rafQueue.size).toBe(1); + const openingFrame = nextRafHandle; + + gradient.disconnect(); + + // Pre-fix this handle was never stored on `animateRaf`, so disconnect() had + // nothing to cancel and the opening frame outlived teardown. + expect(cancelled).toContain(openingFrame); + expect(rafQueue.size).toBe(0); + }); + it('drops an animation frame that lands after disconnect() instead of throwing', () => { const gradient = mountedGradient(); gradient.conf = { playing: true };