From 1a6a10785cfa8a9d2b211df219108f6a0da0def5 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:33:52 +0530 Subject: [PATCH] perf(ui): pause mesh-gradient background when window is unfocused/hidden (#3524) (#4902) Co-authored-by: Steven Enamakel --- app/src/components/MeshGradient.test.tsx | 125 ++++++++++++++++++++--- app/src/components/MeshGradient.tsx | 60 +++++++++++ app/src/lib/meshGradient.d.ts | 7 ++ 3 files changed, 178 insertions(+), 14 deletions(-) diff --git a/app/src/components/MeshGradient.test.tsx b/app/src/components/MeshGradient.test.tsx index 4d48408dc..0956f4ab7 100644 --- a/app/src/components/MeshGradient.test.tsx +++ b/app/src/components/MeshGradient.test.tsx @@ -1,24 +1,45 @@ import { act } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ThemeProvider from '../providers/ThemeProvider'; import { setThemeToken, upsertCustomTheme } from '../store/themeSlice'; import { renderWithProviders } from '../test/test-utils'; import MeshGradient from './MeshGradient'; -const gradientMock = vi.hoisted(() => ({ - disconnect: vi.fn(), - // eslint-disable-next-line prefer-arrow-callback -- constructor mock must be new-able; arrows are not constructible. - Gradient: vi.fn(function MockGradient() { - return { - disconnect: gradientMock.disconnect, - initGradient: gradientMock.initGradient, - pause: gradientMock.pause, - }; - }), - initGradient: vi.fn(), - pause: vi.fn(), -})); +const gradientMock = vi.hoisted(() => { + // Shared play flag, mirroring the real `Gradient.conf.playing`, so the + // component's double-schedule guard (`shouldAnimate === playing`) is exercised. + const conf = { playing: false }; + // `state.mesh` mirrors `Gradient.mesh`: truthy once a WebGL context is + // acquired, `undefined` on no-GPU/headless. The component only calls play() + // when it is set (#3524); a getter lets a test flip it before events fire. + const state: { mesh: unknown } = { mesh: {} }; + return { + conf, + state, + disconnect: vi.fn(), + initGradient: vi.fn(), + pause: vi.fn(() => { + conf.playing = false; + }), + play: vi.fn(() => { + conf.playing = true; + }), + // eslint-disable-next-line prefer-arrow-callback -- constructor mock must be new-able; arrows are not constructible. + Gradient: vi.fn(function MockGradient() { + return { + conf, + get mesh() { + return state.mesh; + }, + disconnect: gradientMock.disconnect, + initGradient: gradientMock.initGradient, + pause: gradientMock.pause, + play: gradientMock.play, + }; + }), + }; +}); vi.mock('../lib/meshGradient', () => ({ Gradient: gradientMock.Gradient })); @@ -30,6 +51,12 @@ describe('', () => { gradientMock.Gradient.mockClear(); gradientMock.initGradient.mockClear(); gradientMock.pause.mockClear(); + gradientMock.play.mockClear(); + gradientMock.conf.playing = false; + gradientMock.state.mesh = {}; // default: WebGL mesh initialized OK + // Default to a visible, focused window so the gradient animates unless a + // test says otherwise. + vi.spyOn(document, 'hasFocus').mockReturnValue(true); rafQueue = []; vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { rafQueue.push(callback); @@ -38,6 +65,10 @@ describe('', () => { vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + function flushAnimationFrames() { const pending = [...rafQueue]; rafQueue = []; @@ -90,4 +121,70 @@ describe('', () => { expect(gradientMock.pause).toHaveBeenCalledTimes(1); expect(gradientMock.initGradient).toHaveBeenCalledTimes(2); }); + + it('pauses the animation when the window loses focus and resumes when it returns (#3524)', () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + + renderWithProviders( + + + + ); + act(() => { + flushAnimationFrames(); + }); + + // Focused + visible on mount → animating. + expect(gradientMock.play).toHaveBeenCalledTimes(1); + expect(gradientMock.conf.playing).toBe(true); + gradientMock.play.mockClear(); + gradientMock.pause.mockClear(); + + // Window backgrounded (occluded/blurred) → the shader must stop rendering. + hasFocus.mockReturnValue(false); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + expect(gradientMock.pause).toHaveBeenCalledTimes(1); + expect(gradientMock.conf.playing).toBe(false); + + // Window refocused → resume. + hasFocus.mockReturnValue(true); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + expect(gradientMock.play).toHaveBeenCalledTimes(1); + expect(gradientMock.conf.playing).toBe(true); + }); + + it('never resumes when the WebGL mesh failed to initialize (no-GPU/Tauri, #3524)', () => { + // Simulate a gradient whose connect() couldn't get a GL context: no `mesh` + // was ever built, yet the real lib leaves `conf.playing` truthy. play() must + // stay suppressed so the animation loop never dereferences the missing mesh. + gradientMock.state.mesh = undefined; + gradientMock.conf.playing = true; + + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true); + renderWithProviders( + + + + ); + act(() => { + flushAnimationFrames(); + }); + + // Blur then refocus — the resume path must NOT call play() without a mesh + // (previously this crashed on the next animation frame). + hasFocus.mockReturnValue(false); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + hasFocus.mockReturnValue(true); + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + expect(gradientMock.play).not.toHaveBeenCalled(); + }); }); diff --git a/app/src/components/MeshGradient.tsx b/app/src/components/MeshGradient.tsx index d52358f5c..cac150ec1 100644 --- a/app/src/components/MeshGradient.tsx +++ b/app/src/components/MeshGradient.tsx @@ -47,6 +47,40 @@ export default function MeshGradient() { } }; + // Only animate when the app window is actually visible AND focused, and the + // user hasn't asked for reduced motion. A full-screen WebGL shader that keeps + // rendering every frame while the app merely sits open in the background + // (occluded/blurred) or minimized is pure wasted GPU/CPU — the sustained + // load users see as the machine running hot with nothing happening (#3524). + const prefersReducedMotion = () => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false; + + const applyPlayState = () => { + if (!gradient) return; + const shouldAnimate = !document.hidden && document.hasFocus() && !prefersReducedMotion(); + // Read the gradient's own play flag so we never double-schedule its rAF + // loop (calling play() while already playing starts a second loop). + const playing = gradient.conf?.playing ?? false; + if (shouldAnimate === playing) return; + try { + // Only (re)start once the WebGL mesh actually initialized. On a no-GPU / + // headless environment `connect()` leaves `conf.playing` truthy without + // ever creating `mesh`, so resuming here would schedule `animate`, which + // dereferences `mesh.material` on the next frame and throws — exactly the + // environment this component is meant to tolerate (#3524). `pause()` is + // always safe (it just clears the flag / cancels any rAF). + if (shouldAnimate) { + if (gradient.mesh) gradient.play(); + } else { + gradient.pause(); + } + } catch { + // Play-state control is best-effort. + } + }; + const start = () => { if (disposed) return; const root = document.documentElement; @@ -67,6 +101,9 @@ export default function MeshGradient() { try { gradient = new Gradient(); gradient.initGradient('#mesh-gradient'); + // Honor the current visibility/focus/reduced-motion state right away, so + // a gradient created while the window is backgrounded never animates. + applyPlayState(); } catch (err) { console.warn('[MeshGradient] WebGL init failed, gradient disabled:', err); gradient = null; @@ -82,6 +119,25 @@ export default function MeshGradient() { scheduleStart(); + // Pause/resume the animation as the window gains or loses visibility/focus + // (and when the reduced-motion preference flips), so it only ever burns + // cycles while the user is actually looking at a focused OpenHuman window. + const onPlayStateChange = () => applyPlayState(); + document.addEventListener('visibilitychange', onPlayStateChange); + window.addEventListener('focus', onPlayStateChange); + window.addEventListener('blur', onPlayStateChange); + let removeMotionListener: (() => void) | undefined; + if (typeof window !== 'undefined' && window.matchMedia) { + const motionMq = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (motionMq.addEventListener) { + motionMq.addEventListener('change', onPlayStateChange); + removeMotionListener = () => motionMq.removeEventListener('change', onPlayStateChange); + } else { + motionMq.addListener(onPlayStateChange); + removeMotionListener = () => motionMq.removeListener(onPlayStateChange); + } + } + let removeSystemListener: (() => void) | undefined; if (variant === 'system' && typeof window !== 'undefined' && window.matchMedia) { const mq = window.matchMedia('(prefers-color-scheme: dark)'); @@ -98,6 +154,10 @@ export default function MeshGradient() { return () => { disposed = true; removeSystemListener?.(); + removeMotionListener?.(); + document.removeEventListener('visibilitychange', onPlayStateChange); + window.removeEventListener('focus', onPlayStateChange); + window.removeEventListener('blur', onPlayStateChange); window.cancelAnimationFrame(raf); disconnectGradient(); }; diff --git a/app/src/lib/meshGradient.d.ts b/app/src/lib/meshGradient.d.ts index c53c0b70f..6b6621fb1 100644 --- a/app/src/lib/meshGradient.d.ts +++ b/app/src/lib/meshGradient.d.ts @@ -5,6 +5,13 @@ export interface GradientConfig { export class Gradient { el?: HTMLCanvasElement; conf?: GradientConfig; + /** + * The WebGL mesh. Only set once `connect()` acquires a GL context and builds + * the geometry; stays `undefined` on no-GPU / headless environments. Callers + * must check it before `play()`, since the animation loop dereferences + * `mesh.material` and would throw when it is absent (#3524). + */ + mesh?: unknown; play(): void; pause(): void; disconnect(): void;