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;