From 072ee4de32bbd97d74df7d9ba742cfbb16f91bc1 Mon Sep 17 00:00:00 2001 From: Mykola Bashmakov Date: Wed, 29 Jul 2026 22:48:08 +0300 Subject: [PATCH] perf(desktop): keep spinner frames out of React commits Advance the existing animated status glyph through its DOM text node instead of React state, and pause its timer for hidden panes or inactive windows. Cover frame advancement, zero update-phase commits, and timer suspension with behavior tests. --- .../src/components/ui/glyph-spinner.test.tsx | 98 +++++++++++++++++++ .../src/components/ui/glyph-spinner.tsx | 54 ++++++++-- 2 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/components/ui/glyph-spinner.test.tsx 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 0000000000000..9fe8c43dd1a84 --- /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 52e82412c881c..1fc50bbe4b83b 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]} ) }