mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
66e46a2064
commit
1a6a10785c
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Vendored
+7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user