mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user