From 1dc96269e53a2ba0dcc9bfe7ef9eae379b0690a8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 08:17:31 +0000 Subject: [PATCH] Optimize GradientBlinds: Pause animation when off-screen - Added IntersectionObserver to track component visibility. - Paused requestAnimationFrame loop when component is not intersecting. - Resumed loop when component becomes visible. - Added unit tests to verify start/stop behavior. --- .../effects/GradientBlinds.test.tsx | 115 ++++++++++++++++++ src/components/effects/GradientBlinds.tsx | 17 +++ 2 files changed, 132 insertions(+) create mode 100644 src/components/effects/GradientBlinds.test.tsx diff --git a/src/components/effects/GradientBlinds.test.tsx b/src/components/effects/GradientBlinds.test.tsx new file mode 100644 index 0000000..4a2789c --- /dev/null +++ b/src/components/effects/GradientBlinds.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { render, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import GradientBlinds from './GradientBlinds'; + +// Mock ogl +vi.mock('ogl', () => { + return { + Renderer: class { + gl = { + // Use a real canvas element so appendChild works + canvas: document.createElement('canvas'), + drawingBufferWidth: 100, + drawingBufferHeight: 100, + }; + setSize() {} + render() {} + }, + Program: class { + remove() {} + }, + Mesh: class { + remove() {} + }, + Triangle: class { + remove() {} + }, + }; +}); + +// Mock ResizeObserver +globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; + +describe('GradientBlinds', () => { + let rafSpy: any; + let cancelRafSpy: any; + let ioCallback: (entries: IntersectionObserverEntry[]) => void; + let observeSpy: any; + let disconnectSpy: any; + + beforeEach(() => { + rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((_cb: any) => { + return 123; + }); + cancelRafSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((_id: number) => { + }); + + // Mock IntersectionObserver + observeSpy = vi.fn(); + disconnectSpy = vi.fn(); + (globalThis as any).IntersectionObserver = class IntersectionObserver { + constructor(cb: any) { + ioCallback = cb; + } + observe = observeSpy; + unobserve = vi.fn(); + disconnect = disconnectSpy; + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('starts animation loop on mount', () => { + const { unmount } = render(); + expect(rafSpy).toHaveBeenCalled(); + unmount(); + expect(cancelRafSpy).toHaveBeenCalled(); + }); + + it('pauses animation loop when off-screen and resumes when on-screen', () => { + const { unmount } = render(); + + // Initial start + expect(rafSpy).toHaveBeenCalledTimes(1); + + // Reset spies to check for subsequent calls + rafSpy.mockClear(); + cancelRafSpy.mockClear(); + + // Simulate off-screen + act(() => { + if (ioCallback) { + ioCallback([{ isIntersecting: false } as IntersectionObserverEntry]); + } + }); + + // Should cancel animation frame + expect(cancelRafSpy).toHaveBeenCalled(); + + // Should NOT request new frame (loop stopped) + expect(rafSpy).not.toHaveBeenCalled(); + + // Reset spies + cancelRafSpy.mockClear(); + + // Simulate on-screen + act(() => { + if (ioCallback) { + ioCallback([{ isIntersecting: true } as IntersectionObserverEntry]); + } + }); + + // Should restart animation loop + expect(rafSpy).toHaveBeenCalled(); + + unmount(); + expect(disconnectSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/components/effects/GradientBlinds.tsx b/src/components/effects/GradientBlinds.tsx index b926c84..735c48e 100644 --- a/src/components/effects/GradientBlinds.tsx +++ b/src/components/effects/GradientBlinds.tsx @@ -333,11 +333,28 @@ void main() { } } }; + + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + if (!rafRef.current) { + lastTimeRef.current = 0; + rafRef.current = requestAnimationFrame(loop); + } + } else { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + } + }); + observer.observe(container); + rafRef.current = requestAnimationFrame(loop); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); canvas.removeEventListener('pointermove', onPointerMove); + observer.disconnect(); ro.disconnect(); if (canvas.parentElement === container) { container.removeChild(canvas);