Preserve loader behavior while reducing hidden work (#1996)

* fix(app): preserve loader behavior in retained panels

Keep the running footer mounted across panel switches. Inactive loaders stop their animation work, then realign to the original wall-clock phase before they become visible.

* perf(app): remove inactive loader wrapper worklet

* perf(app): pause hidden turn elapsed timers

* perf(app): reduce loader animation work

Keep one wall-clock-aligned clock for visible loaders and detach retained
hidden loaders without unmounting them. Preserve the 950 ms sequence and
reduced-motion behavior.
This commit is contained in:
Mohamed Boudra
2026-07-11 13:25:24 +02:00
committed by GitHub
parent 5d18edd31e
commit adc9b3b43c
10 changed files with 182 additions and 123 deletions
@@ -18,6 +18,7 @@ import {
} from "@/components/message";
import type { TurnFooterHost } from "./layout";
import { SyncedLoader } from "@/components/synced-loader";
import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({
@@ -35,23 +36,18 @@ export type AssistantTurnForkHandler = (input: {
export const TurnFooter = memo(function TurnFooter({
isRunning,
isActive,
inFlightTurnStartedAt,
host,
strategy,
onForkAssistantTurn,
}: {
isRunning: boolean;
isActive: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
onForkAssistantTurn?: AssistantTurnForkHandler;
}) {
if (isRunning) {
if (!isActive) {
return null;
}
return (
<TurnFooterRow>
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
@@ -103,6 +99,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
}: {
inFlightTurnStartedAt?: Date | null;
}) {
const active = useRetainedPanelActive();
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingLoader}>
@@ -111,6 +108,7 @@ const WorkingIndicator = memo(function WorkingIndicator({
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
active={active}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
-2
View File
@@ -772,7 +772,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
showRunningTurnFooter || bottomTurnFooterHost ? (
<TurnFooter
isRunning={showRunningTurnFooter}
isActive={isActive}
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
@@ -781,7 +780,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
) : null,
[
handleForkAssistantTurn,
isActive,
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
+8 -2
View File
@@ -692,6 +692,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
interface LiveElapsedProps {
startedAt: Date;
active?: boolean;
style?: StyleProp<TextStyle>;
testID?: string;
}
@@ -702,23 +703,28 @@ interface LiveElapsedProps {
*/
export const LiveElapsed = memo(function LiveElapsed({
startedAt,
active = true,
style,
testID,
}: LiveElapsedProps) {
const startedAtMs = startedAt.getTime();
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
const visibleElapsedMs = active ? Math.max(0, Date.now() - startedAtMs) : elapsedMs;
useEffect(() => {
if (!active) {
return;
}
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
const handle = setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
}, 1000);
return () => clearInterval(handle);
}, [startedAtMs]);
}, [active, startedAtMs]);
return (
<Text style={style} testID={testID}>
{formatDuration(elapsedMs)}
{formatDuration(visibleElapsedMs)}
</Text>
);
});
@@ -1,33 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSharedStepClock } from "@/components/synced-loader-clock";
afterEach(() => {
vi.useRealTimers();
});
describe("createSharedStepClock", () => {
it("advances a shared cadence only while at least one consumer is subscribed", () => {
vi.useFakeTimers();
const clock = createSharedStepClock(6, 960);
const firstListener = vi.fn();
const secondListener = vi.fn();
const unsubscribeFirst = clock.subscribe(firstListener);
const unsubscribeSecond = clock.subscribe(secondListener);
vi.advanceTimersByTime(320);
expect(clock.getSnapshot()).toBe(2);
expect(firstListener).toHaveBeenCalledTimes(2);
expect(secondListener).toHaveBeenCalledTimes(2);
unsubscribeFirst();
vi.advanceTimersByTime(160);
expect(firstListener).toHaveBeenCalledTimes(2);
expect(secondListener).toHaveBeenCalledTimes(3);
unsubscribeSecond();
vi.advanceTimersByTime(640);
expect(clock.getSnapshot()).toBe(3);
expect(secondListener).toHaveBeenCalledTimes(3);
});
});
@@ -1,36 +0,0 @@
export interface SharedStepClock {
getSnapshot: () => number;
subscribe: (listener: () => void) => () => void;
}
export function createSharedStepClock(stepCount: number, cycleDurationMs: number): SharedStepClock {
const normalizedStepCount = Math.max(1, Math.floor(stepCount));
const stepDurationMs = Math.max(1, Math.round(cycleDurationMs / normalizedStepCount));
const listeners = new Set<() => void>();
let step = 0;
let timer: ReturnType<typeof setInterval> | null = null;
function advance(): void {
step = (step + 1) % normalizedStepCount;
for (const listener of listeners) {
listener();
}
}
return {
getSnapshot: () => step,
subscribe: (listener) => {
listeners.add(listener);
if (timer === null) {
timer = setInterval(advance, stepDurationMs);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && timer !== null) {
clearInterval(timer);
timer = null;
}
};
},
};
}
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { getSyncedLoaderDotOpacity, getSyncedLoaderStep } from "./synced-loader-state";
describe("synced loader state", () => {
test("advances through six wall-clock-aligned steps every 950 milliseconds", () => {
const sampleTimes = [0, 158, 159, 316, 317, 474, 475, 633, 634, 791, 792, 949, 950];
const steps = sampleTimes.map(getSyncedLoaderStep);
expect(steps).toEqual([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 0]);
});
test("preserves the six visible snake states", () => {
const states: number[][] = [];
for (let step = 0; step < 6; step += 1) {
const dotOpacities: number[] = [];
for (let dot = 0; dot < 6; dot += 1) {
dotOpacities.push(getSyncedLoaderDotOpacity(step, dot));
}
states.push(dotOpacities);
}
expect(states).toEqual([
[1, 0, 0.78, 0, 0.56, 0.34],
[0.78, 1, 0.56, 0, 0.34, 0],
[0.56, 0.78, 0.34, 1, 0, 0],
[0.34, 0.56, 0, 0.78, 0, 1],
[0, 0.34, 0, 0.56, 1, 0.78],
[0, 0, 1, 0.34, 0.78, 0.56],
]);
});
});
@@ -0,0 +1,22 @@
const SYNCED_LOADER_DURATION_MS = 950;
export const SYNCED_LOADER_DOT_COUNT = 6;
const SYNCED_LOADER_OPACITY_STATES = [
[1, 0, 0.78, 0, 0.56, 0.34],
[0.78, 1, 0.56, 0, 0.34, 0],
[0.56, 0.78, 0.34, 1, 0, 0],
[0.34, 0.56, 0, 0.78, 0, 1],
[0, 0.34, 0, 0.56, 1, 0.78],
[0, 0, 1, 0.34, 0.78, 0.56],
] as const;
export function getSyncedLoaderStep(nowMs: number): number {
"worklet";
const elapsedMs = nowMs % SYNCED_LOADER_DURATION_MS;
return Math.floor((elapsedMs * SYNCED_LOADER_DOT_COUNT) / SYNCED_LOADER_DURATION_MS);
}
export function getSyncedLoaderDotOpacity(step: number, dot: number): number {
"worklet";
return SYNCED_LOADER_OPACITY_STATES[step]?.[dot] ?? 0;
}
+113 -44
View File
@@ -1,26 +1,101 @@
import { useMemo, useSyncExternalStore } from "react";
import { useLayoutEffect, useMemo, useState } from "react";
import { View } from "react-native";
import Animated, {
makeMutable,
type SharedValue,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
} from "react-native-reanimated";
import { scheduleOnUI } from "react-native-worklets";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { createSharedStepClock } from "@/components/synced-loader-clock";
import {
SYNCED_LOADER_DOT_COUNT,
getSyncedLoaderDotOpacity,
getSyncedLoaderStep,
} from "@/components/synced-loader-state";
const SYNCED_LOADER_DURATION_MS = 6_000;
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const DOT_INDEXES = Array.from({ length: DOT_COUNT }, (_, dotIndex) => dotIndex);
const DOT_KEYS = DOT_INDEXES.map((dotIndex) => `dot-${dotIndex}`);
const sharedStepClock = createSharedStepClock(DOT_COUNT, SYNCED_LOADER_DURATION_MS);
const subscribePaused = () => () => {};
const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`);
const sharedStep = makeMutable(0);
const activeLoaderCount = makeMutable(0);
const clockRunning = makeMutable(false);
let nextStepListenerId = 1;
function advanceSharedStep(): void {
"worklet";
if (activeLoaderCount.value === 0) {
clockRunning.value = false;
return;
}
const nextStep = getSyncedLoaderStep(Date.now());
if (sharedStep.value !== nextStep) {
sharedStep.value = nextStep;
}
requestAnimationFrame(advanceSharedStep);
}
function registerStepListener(
step: SharedValue<number>,
registered: SharedValue<boolean>,
listenerId: number,
): void {
"worklet";
if (registered.value) {
return;
}
registered.value = true;
step.value = getSyncedLoaderStep(Date.now());
sharedStep.addListener(listenerId, (nextStep) => {
step.value = nextStep;
});
activeLoaderCount.value += 1;
if (!clockRunning.value) {
clockRunning.value = true;
sharedStep.value = step.value;
requestAnimationFrame(advanceSharedStep);
}
}
function unregisterStepListener(registered: SharedValue<boolean>, listenerId: number): void {
"worklet";
if (!registered.value) {
return;
}
registered.value = false;
sharedStep.removeListener(listenerId);
activeLoaderCount.value -= 1;
}
function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue<number> {
// The local value lets retained loaders detach from the app-wide clock without
// unmounting their animated views or leaving hidden style worklets subscribed.
const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now()));
const registered = useSharedValue(false);
const [listenerId] = useState(() => nextStepListenerId++);
useLayoutEffect(() => {
if (!active || reduceMotion) {
return;
}
scheduleOnUI(registerStepListener, step, registered, listenerId);
return () => {
scheduleOnUI(unregisterStepListener, registered, listenerId);
};
}, [active, listenerId, reduceMotion, registered, step]);
return step;
}
export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) {
const active = useRetainedPanelActive();
const step = useSyncExternalStore(
active ? sharedStepClock.subscribe : subscribePaused,
sharedStepClock.getSnapshot,
sharedStepClock.getSnapshot,
);
const reduceMotion = useReducedMotion();
const step = useSyncedLoaderStep(active, reduceMotion);
const gap = Math.max(1, Math.round(size * 0.12));
const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
@@ -45,17 +120,16 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
return (
<View style={containerStyle}>
<View style={gridStyle}>
{DOT_INDEXES.map((dotIndex) => {
{Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
return (
<SpinnerDot
key={DOT_KEYS[dotIndex]}
color={color}
dotSize={dotSize}
sequenceIndex={sequenceIndex}
dotIndex={dotIndex}
step={step}
left={columnIndex * (dotSize + gap)}
top={rowIndex * (dotSize + gap)}
@@ -70,42 +144,37 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
function SpinnerDot({
color,
dotSize,
sequenceIndex,
dotIndex,
step,
left,
top,
}: {
color: string;
dotSize: number;
sequenceIndex: number;
step: number;
dotIndex: number;
step: SharedValue<number>;
left: number;
top: number;
}) {
let opacity = 0;
for (let segmentIndex = 0; segmentIndex < SNAKE_SEGMENT_OFFSETS.length; segmentIndex += 1) {
const activeSequenceIndex =
(step + SNAKE_SEGMENT_OFFSETS[segmentIndex] + DOT_COUNT) % DOT_COUNT;
if (sequenceIndex === activeSequenceIndex) {
opacity = SNAKE_OPACITIES[segmentIndex] ?? 0;
break;
}
}
const animatedStyle = useAnimatedStyle(() => ({
opacity: getSyncedLoaderDotOpacity(step.value, dotIndex),
}));
const dotStyle = useMemo(
() => ({
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
position: "absolute" as const,
left,
top,
opacity,
}),
[color, dotSize, left, opacity, top],
() => [
animatedStyle,
{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
position: "absolute" as const,
left,
top,
},
],
[animatedStyle, dotSize, color, left, top],
);
return <View style={dotStyle} />;
return <Animated.View style={dotStyle} />;
}