From ebd74e681ba9b65425e087e308032bb95e228f4e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 3 Sep 2026 05:47:06 -0400 Subject: [PATCH] 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 --- .../animation/animated-rotation-loop.test.ts | 92 +++++++++++++++++++ .../src/animation/animated-rotation-loop.ts | 25 +++++ mobile/src/components/AgentSpinner.tsx | 20 ++-- mobile/src/components/AgentStateDot.tsx | 21 ++--- .../src/components/agent-busy-ring.test.tsx | 38 ++++++++ mobile/src/components/agent-busy-ring.ts | 9 ++ .../agent-monitoring-indicators.test.ts | 1 + 7 files changed, 181 insertions(+), 25 deletions(-) create mode 100644 mobile/src/animation/animated-rotation-loop.test.ts create mode 100644 mobile/src/animation/animated-rotation-loop.ts create mode 100644 mobile/src/components/agent-busy-ring.test.tsx create mode 100644 mobile/src/components/agent-busy-ring.ts diff --git a/mobile/src/animation/animated-rotation-loop.test.ts b/mobile/src/animation/animated-rotation-loop.test.ts new file mode 100644 index 00000000000..2c3bb06f621 --- /dev/null +++ b/mobile/src/animation/animated-rotation-loop.test.ts @@ -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 }) + ) + }) +}) diff --git a/mobile/src/animation/animated-rotation-loop.ts b/mobile/src/animation/animated-rotation-loop.ts new file mode 100644 index 00000000000..04e4b9ff0b2 --- /dev/null +++ b/mobile/src/animation/animated-rotation-loop.ts @@ -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 + }) + ) +} diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index c05e5d2a489..d24549b1362 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -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 ( - + ) } @@ -94,7 +91,6 @@ const styles = StyleSheet.create({ width: 8, height: 8, borderRadius: 4, - borderWidth: 2, - borderTopColor: 'transparent' + borderWidth: 2 } }) diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 1637296d900..53a68574fde 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -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 ( - + ) } @@ -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 } }) diff --git a/mobile/src/components/agent-busy-ring.test.tsx b/mobile/src/components/agent-busy-ring.test.tsx new file mode 100644 index 00000000000..77713433b81 --- /dev/null +++ b/mobile/src/components/agent-busy-ring.test.tsx @@ -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)') + }) +}) diff --git a/mobile/src/components/agent-busy-ring.ts b/mobile/src/components/agent-busy-ring.ts new file mode 100644 index 00000000000..a66dd3c0592 --- /dev/null +++ b/mobile/src/components/agent-busy-ring.ts @@ -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' } +} diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index eefa2137f14..7eb5a3bb740 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -30,6 +30,7 @@ vi.mock('react-native', () => ({ timing: animationTiming }, Easing: { linear: 'linear' }, + Platform: { OS: 'ios' }, StyleSheet: { create: (styles: T) => styles }, View: 'View' }))