Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions apps/desktop/src/components/ui/glyph-spinner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Profiler id="glyph-spinner" onRender={onRender}>
<GlyphSpinner spinner="braille" />
</Profiler>
)

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(
<PaneVisibleContext.Provider value={false}>
<GlyphSpinner spinner="braille" />
</PaneVisibleContext.Provider>
)

const status = screen.getByRole('status', { name: 'Loading' })

expect(status.textContent).toBe('β ‹')
expect(vi.getTimerCount()).toBe(0)

rerender(
<PaneVisibleContext.Provider value>
<GlyphSpinner spinner="braille" />
</PaneVisibleContext.Provider>
)
expect(vi.getTimerCount()).toBe(1)

act(() => vi.advanceTimersByTime(80))
expect(status.textContent).toBe('β ™')

rerender(
<PaneVisibleContext.Provider value={false}>
<GlyphSpinner spinner="braille" />
</PaneVisibleContext.Provider>
)
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', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test validates the blur/focus controller path, but the newly adopted controller also pauses from native minimized/hidden state and document visibility. Please add a mocked window.hermesDesktop.onWindowStateChanged transition here so GlyphSpinner itself proves its interval is cleared on minimize/hide and restored on visibility; the controller handles that path at apps/desktop/src/lib/renderer-loop-pause.ts:26-35.

render(<GlyphSpinner spinner="braille" />)

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)
})
})
54 changes: 46 additions & 8 deletions apps/desktop/src/components/ui/glyph-spinner.tsx
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down Expand Up @@ -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<HTMLSpanElement>(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<typeof createRendererLoopPauseController> | 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 (
<span
aria-label={ariaLabel}
className={cn('inline-flex items-center justify-center font-mono leading-none tabular-nums', className)}
ref={glyphRef}
role="status"
>
{spin.frames[frame]}
{spin.frames[0]}
</span>
)
}