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]}
)
}