diff --git a/apps/desktop/src/components/ui/glyph-spinner.test.tsx b/apps/desktop/src/components/ui/glyph-spinner.test.tsx new file mode 100644 index 000000000000..9fe8c43dd1a8 --- /dev/null +++ b/apps/desktop/src/components/ui/glyph-spinner.test.tsx @@ -0,0 +1,98 @@ +import { act, render, screen } from '@testing-library/react' +import { Profiler, type ProfilerOnRenderCallback } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility' + +import { GlyphSpinner } from './glyph-spinner' + +describe('GlyphSpinner', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(globalThis.document, 'hasFocus').mockReturnValue(true) + }) + + afterEach(() => { + vi.clearAllTimers() + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('advances its glyph without an update-phase React commit', () => { + let updateCommits = 0 + + const onRender: ProfilerOnRenderCallback = (_id, phase) => { + if (phase !== 'mount') { + updateCommits += 1 + } + } + + render( + + + + ) + + const status = screen.getByRole('status', { name: 'Loading' }) + expect(status.textContent).toBe('⠋') + + act(() => vi.advanceTimersByTime(80)) + + expect(status.textContent).toBe('⠙') + expect(updateCommits).toBe(0) + }) + + it('does not tick while its kept-alive pane is hidden', () => { + const { rerender } = render( + + + + ) + + const status = screen.getByRole('status', { name: 'Loading' }) + + expect(status.textContent).toBe('⠋') + expect(vi.getTimerCount()).toBe(0) + + rerender( + + + + ) + expect(vi.getTimerCount()).toBe(1) + + act(() => vi.advanceTimersByTime(80)) + expect(status.textContent).toBe('⠙') + + rerender( + + + + ) + expect(vi.getTimerCount()).toBe(0) + + const frozen = status.textContent + act(() => vi.advanceTimersByTime(800)) + expect(status.textContent).toBe(frozen) + }) + + it('suspends animation while the Desktop window is inactive', () => { + render() + + const status = screen.getByRole('status', { name: 'Loading' }) + expect(vi.getTimerCount()).toBe(1) + + act(() => window.dispatchEvent(new Event('blur'))) + expect(vi.getTimerCount()).toBe(0) + + const frozen = status.textContent + act(() => vi.advanceTimersByTime(800)) + expect(status.textContent).toBe(frozen) + + act(() => window.dispatchEvent(new Event('focus'))) + expect(vi.getTimerCount()).toBe(1) + + act(() => vi.advanceTimersByTime(80)) + expect(status.textContent).not.toBe(frozen) + }) +}) diff --git a/apps/desktop/src/components/ui/glyph-spinner.tsx b/apps/desktop/src/components/ui/glyph-spinner.tsx index 52e82412c881..1fc50bbe4b83 100644 --- a/apps/desktop/src/components/ui/glyph-spinner.tsx +++ b/apps/desktop/src/components/ui/glyph-spinner.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef } from 'react' import spinners, { type BrailleSpinnerName as SpinnerName } from 'unicode-animations' import { usePaneVisible } from '@/components/pane-shell/pane-visibility' +import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause' import { cn } from '@/lib/utils' export type { SpinnerName } @@ -43,29 +44,66 @@ interface GlyphSpinnerProps { */ export function GlyphSpinner({ ariaLabel = 'Loading', className, spinner = 'braille' }: GlyphSpinnerProps) { const spin = FRAMES_BY_NAME[spinner] ?? FRAMES_BY_NAME.braille! - const [frame, setFrame] = useState(0) + const glyphRef = useRef(null) // Pause when this surface is a hidden (kept-alive) tab: N mounted tabs each - // ticking a setInterval + setState burn CPU for pixels nobody can see. + // ticking a setInterval burns CPU for pixels nobody can see. const visible = usePaneVisible() useEffect(() => { - if (!visible) { + const glyph = glyphRef.current + + if (!visible || !glyph) { return } - setFrame(0) - const id = window.setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) + let frame = 0 + let timer: number | undefined + let pauseController: ReturnType | undefined + glyph.textContent = spin.frames[frame] + + const stopAnimation = () => { + if (timer === undefined) { + return + } + + window.clearInterval(timer) + timer = undefined + } + + const syncAnimation = () => { + if (pauseController?.isPaused()) { + stopAnimation() + + return + } - return () => window.clearInterval(id) + if (timer !== undefined) { + return + } + + timer = window.setInterval(() => { + frame = (frame + 1) % spin.frames.length + glyph.textContent = spin.frames[frame] + }, spin.interval) + } + + pauseController = createRendererLoopPauseController(syncAnimation) + syncAnimation() + + return () => { + pauseController.dispose() + stopAnimation() + } }, [spin, visible]) return ( - {spin.frames[frame]} + {spin.frames[0]} ) }