fix(mobile): keep hybrid busy spinners animating on react-native-web

Animated.loop branches on the raw `useNativeDriver` flag, not on driver
availability. On react-native-web that sends the loop down `_startNativeLoop`,
which starts one JS-driven timing pass whose `iterations` the JS driver ignores,
so the spinner ramps 0 -> 1 once and freezes. Verified against the real
react-native-web Animated: 3.5s of frames yields zero restarts with the flag on,
3+ with it off.

The same components also lost their ring gap on web: an inline `borderColor`
after a StyleSheet `borderTopColor` is emitted as four inline longhands that
outrank the class, rendering a solid ring instead of an arc.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-03 05:47:06 -04:00
parent 543b51f7e8
commit ebd74e681b
7 changed files with 181 additions and 25 deletions
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Why the NODE_ENV dance: react-native-web swaps its whole Animated implementation for a no-op
// AnimatedMock whenever NODE_ENV === 'test', so the real web driver is only observable if the
// module graph is loaded under another env. `platform.os` lets one mock serve both platforms.
const platform = vi.hoisted(() => ({ os: 'web' as string }))
vi.mock('react-native', async () => {
const previous = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
try {
const web = await import('react-native-web')
return {
Animated: web.Animated,
Easing: web.Easing,
Platform: {
get OS() {
return platform.os
}
}
}
} finally {
process.env.NODE_ENV = previous
}
})
/** Drives Animated's JS driver: it reads Date.now(), so fake timers must move the clock. */
function installFrameClock(): void {
vi.useFakeTimers()
Object.assign(globalThis, {
requestAnimationFrame: (callback: (timestamp: number) => void) =>
setTimeout(() => callback(Date.now()), 16) as unknown as number,
cancelAnimationFrame: (handle: number) => clearTimeout(handle as never)
})
}
async function loadRotationLoop() {
vi.resetModules()
return await import('./animated-rotation-loop')
}
describe('createRotationLoop', () => {
beforeEach(() => {
platform.os = 'web'
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('keeps rotating past the first turn on react-native-web', async () => {
const { Animated } = await import('react-native')
const { createRotationLoop } = await loadRotationLoop()
installFrameClock()
vi.spyOn(console, 'warn').mockImplementation(() => {})
const value = new Animated.Value(0)
const samples: number[] = []
value.addListener(({ value: sample }: { value: number }) => samples.push(sample))
const animation = createRotationLoop(value, 1000)
animation.start()
vi.advanceTimersByTime(3500)
animation.stop()
// Each restart drops the value back toward 0; a single non-looping pass produces none.
const turns = samples.filter((sample, index) => index > 0 && sample < samples[index - 1]).length
expect(turns).toBeGreaterThanOrEqual(3)
expect(samples.at(-1)).toBeLessThan(1)
})
it('does not claim the missing native driver on web', async () => {
const { nativeAnimatedDriverSupported } = await loadRotationLoop()
expect(nativeAnimatedDriverSupported).toBe(false)
})
it('still drives the native builds with the native driver', async () => {
platform.os = 'android'
const reactNative = await import('react-native')
const timing = vi.spyOn(reactNative.Animated, 'timing')
const { createRotationLoop, nativeAnimatedDriverSupported } = await loadRotationLoop()
createRotationLoop(new reactNative.Animated.Value(0), 1000)
expect(nativeAnimatedDriverSupported).toBe(true)
expect(timing).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ toValue: 1, duration: 1000, useNativeDriver: true })
)
})
})
@@ -0,0 +1,25 @@
import { Animated, Easing, Platform } from 'react-native'
// Why: react-native-web ships no native animated module, yet `Animated.loop` still branches on
// the raw `useNativeDriver` flag rather than on driver availability. With `true` it takes the
// `_startNativeLoop` path, which on web starts a single JS-driven timing pass with a config the
// JS driver ignores `iterations` from — the value ramps 0 -> 1 once and then stops forever, so
// every looping spinner freezes in the hybrid WebView. Only claim the driver where it exists.
export const nativeAnimatedDriverSupported = Platform.OS !== 'web'
const SPIN_DURATION_MS = 1000
/** Continuous 0 -> 1 rotation driver shared by the worktree and per-agent busy spinners. */
export function createRotationLoop(
value: Animated.Value,
durationMs: number = SPIN_DURATION_MS
): Animated.CompositeAnimation {
return Animated.loop(
Animated.timing(value, {
toValue: 1,
duration: durationMs,
easing: Easing.linear,
useNativeDriver: nativeAnimatedDriverSupported
})
)
}
+8 -12
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Activity } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import { Animated, StyleSheet, View } from 'react-native'
import { createRotationLoop } from '../animation/animated-rotation-loop'
import { busyRingColors } from './agent-busy-ring'
import type { AgentWorkingMode } from '../../../src/shared/agent-status-types'
type WorktreeStatus = 'working' | 'active' | 'permission' | 'done' | 'inactive'
@@ -31,14 +33,7 @@ export function AgentSpinner({
useEffect(() => {
if (status === 'working' && !monitoring) {
const animation = Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true
})
)
const animation = createRotationLoop(spinValue)
animation.start()
return () => animation.stop()
}
@@ -62,7 +57,9 @@ export function AgentSpinner({
})
return (
<View style={styles.wrapper}>
<Animated.View style={[styles.spinner, { borderColor: color, transform: [{ rotate }] }]} />
<Animated.View
style={[styles.spinner, busyRingColors(color), { transform: [{ rotate }] }]}
/>
</View>
)
}
@@ -94,7 +91,6 @@ const styles = StyleSheet.create({
width: 8,
height: 8,
borderRadius: 4,
borderWidth: 2,
borderTopColor: 'transparent'
borderWidth: 2
}
})
+8 -13
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react'
import { Activity } from 'lucide-react-native'
import { Animated, Easing, StyleSheet, View } from 'react-native'
import { Animated, StyleSheet, View } from 'react-native'
import { createRotationLoop } from '../animation/animated-rotation-loop'
import { busyRingColors } from './agent-busy-ring'
import type { AgentDotState } from '../worktree/agent-row-display'
// Per-agent state indicator, 1:1 with desktop AgentStateDot
@@ -22,14 +24,7 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
useEffect(() => {
if (state === 'working') {
const animation = Animated.loop(
Animated.timing(spinValue, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true
})
)
const animation = createRotationLoop(spinValue)
animation.start()
return () => animation.stop()
}
@@ -41,7 +36,9 @@ export function AgentStateDot({ state }: { state: AgentDotState }) {
const rotate = spinValue.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] })
return (
<View style={styles.wrapper}>
<Animated.View style={[styles.spinner, { transform: [{ rotate }] }]} />
<Animated.View
style={[styles.spinner, busyRingColors(WORKING_COLOR), { transform: [{ rotate }] }]}
/>
</View>
)
}
@@ -68,8 +65,6 @@ const styles = StyleSheet.create({
width: 6,
height: 6,
borderRadius: 3,
borderWidth: 1.5,
borderColor: WORKING_COLOR,
borderTopColor: 'transparent'
borderWidth: 1.5
}
})
@@ -0,0 +1,38 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { AgentSpinner } from './AgentSpinner'
import { AgentStateDot } from './AgentStateDot'
import { busyRingColors } from './agent-busy-ring'
// Renders the real components through react-native-web, which is what the hybrid WebView loads.
vi.mock('react-native', async () => await import('react-native-web'))
vi.mock('lucide-react-native', () => ({ Activity: () => null }))
const TRANSPARENT = 'rgba(0,0,0,0.00)'
function inlineStyle(markup: string): string {
return markup.match(/style="([^"]*)"/)?.[1] ?? ''
}
describe('busy spinner ring on react-native-web', () => {
it('puts the transparent gap after the ring color', () => {
expect(Object.keys(busyRingColors('#eab308'))).toEqual(['borderColor', 'borderTopColor'])
})
it('leaves the worktree spinner top edge transparent', () => {
const style = inlineStyle(
renderToStaticMarkup(createElement(AgentSpinner, { status: 'working' }))
)
expect(style).toContain(`border-top-color:${TRANSPARENT}`)
expect(style).toContain('border-right-color:rgba(234,179,8,1.00)')
})
it('leaves the agent state dot top edge transparent', () => {
const style = inlineStyle(
renderToStaticMarkup(createElement(AgentStateDot, { state: 'working' }))
)
expect(style).toContain(`border-top-color:${TRANSPARENT}`)
expect(style).toContain('border-right-color:rgba(234,179,8,1.00)')
})
})
+9
View File
@@ -0,0 +1,9 @@
/** Ring colors for the busy spinner: a full-color border with the top edge cut out.
*
* Why a helper: on react-native-web `borderColor` is emitted as four inline longhands, so a
* later `borderColor` in the style array silently overwrites a `borderTopColor: 'transparent'`
* set by an earlier StyleSheet rule and the spinner renders as a solid ring. Keeping both keys
* in one object, gap last, is the only ordering that survives on web and on native. */
export function busyRingColors(color: string): { borderColor: string; borderTopColor: string } {
return { borderColor: color, borderTopColor: 'transparent' }
}
@@ -30,6 +30,7 @@ vi.mock('react-native', () => ({
timing: animationTiming
},
Easing: { linear: 'linear' },
Platform: { OS: 'ios' },
StyleSheet: { create: <T>(styles: T) => styles },
View: 'View'
}))