perf(ui): pause mesh-gradient background when window is unfocused/hidden (#3524) (#4902)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-07-16 03:03:52 +03:00
committed by GitHub
co-authored by Steven Enamakel
parent 66e46a2064
commit 1a6a10785c
3 changed files with 178 additions and 14 deletions
+111 -14
View File
@@ -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('<MeshGradient />', () => {
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('<MeshGradient />', () => {
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
function flushAnimationFrames() {
const pending = [...rafQueue];
rafQueue = [];
@@ -90,4 +121,70 @@ describe('<MeshGradient />', () => {
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(
<ThemeProvider>
<MeshGradient />
</ThemeProvider>
);
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(
<ThemeProvider>
<MeshGradient />
</ThemeProvider>
);
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();
});
});
+60
View File
@@ -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();
};
+7
View File
@@ -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;