perf(app): defer replica persistence until user inactivity

Whole-replica capture and serialization blocked the JS thread during navigation and streaming. Defer that work until the user has been inactive while allowing long-running streams to persist without becoming the idle signal.
This commit is contained in:
Mohamed Boudra
2026-08-22 20:57:13 +02:00
parent 65fbe38dd3
commit 33cf6cf337
8 changed files with 100 additions and 16 deletions
+10 -1
View File
@@ -953,9 +953,18 @@ function RootProviders({ children }: { children: ReactNode }) {
);
}
function recordUserActivity(): void {
getHostRuntimeStore().recordUserActivity();
}
function RootAppTree() {
return (
<GestureHandlerRootView style={flexStyle}>
<GestureHandlerRootView
style={flexStyle}
onTouchStart={recordUserActivity}
onTouchEnd={recordUserActivity}
onTouchCancel={recordUserActivity}
>
<View style={layoutStyles.surfaceFill}>
<RootProviders>
<RuntimeProviders>
@@ -408,7 +408,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
// Client activity tracking (heartbeat, push token registration)
useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed });
useClientActivity({
client,
focusedAgentId,
focusedTerminalId,
onUserActivity: () => getHostRuntimeStore().recordUserActivity(),
onAppResumed: handleAppResumed,
});
useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]);
const notifyAgentAttention = useCallback(
@@ -68,12 +68,26 @@ function buildTracker(
initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null,
initialAppVisible: overrides.initialAppVisible ?? true,
now: clock.now,
onUserActivity: overrides.onUserActivity ?? (() => undefined),
onAppResumed: overrides.onAppResumed,
});
return { tracker, client, clock };
}
describe("client activity tracker", () => {
it("notifies local consumers about user activity", () => {
let notifications = 0;
const { tracker } = buildTracker({
onUserActivity: () => {
notifications += 1;
},
});
tracker.recordUserActivity();
expect(notifications).toBe(1);
});
it("includes the latest user-activity time in the next heartbeat", () => {
const { tracker, client, clock } = buildTracker();
@@ -23,6 +23,7 @@ export interface ClientActivityTrackerInput {
initialFocusedTerminalId: string | null;
initialAppVisible: boolean;
now: () => number;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void;
}
@@ -39,7 +40,7 @@ export interface ClientActivityTracker {
export function createClientActivityTracker(
input: ClientActivityTrackerInput,
): ClientActivityTracker {
const { client, deviceType, now, onAppResumed } = input;
const { client, deviceType, now, onUserActivity, onAppResumed } = input;
let lastActivityAtMs = now();
let appVisible = input.initialAppVisible;
let appVisibilityChangedAtMs = now();
@@ -62,6 +63,7 @@ export function createClientActivityTracker(
function recordUserActivity(): void {
lastActivityAtMs = now();
onUserActivity();
}
function maybeSendImmediateHeartbeat(): void {
@@ -15,6 +15,7 @@ interface ClientActivityOptions {
client: DaemonClient;
focusedAgentId: string | null;
focusedTerminalId: string | null;
onUserActivity: () => void;
onAppResumed?: (awayMs: number) => void;
}
@@ -28,6 +29,7 @@ export function useClientActivity({
client,
focusedAgentId,
focusedTerminalId,
onUserActivity,
onAppResumed,
}: ClientActivityOptions): void {
const onAppResumedRef = useRef(onAppResumed);
@@ -42,6 +44,7 @@ export function useClientActivity({
initialFocusedTerminalId: focusedTerminalId,
initialAppVisible: AppState.currentState === "active",
now: () => Date.now(),
onUserActivity,
onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs),
});
}
+4
View File
@@ -1390,6 +1390,10 @@ export class HostRuntimeStore {
return this.hostRegistryStatus;
}
recordUserActivity(): void {
this.replicaCache.recordUserActivity();
}
subscribeHostList(listener: () => void): () => void {
this.hostListListeners.add(listener);
return () => {
@@ -209,6 +209,35 @@ afterEach(() => {
});
describe("ReplicaCache", () => {
it("persists after user inactivity even while replica changes continue", async () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
seedSession();
await cache.flush();
cache.start();
const writesBeforeChange = storage.writes;
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("first", "First")]]]));
await vi.advanceTimersByTimeAsync(4_000);
cache.recordUserActivity();
await vi.advanceTimersByTimeAsync(1_000);
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("second", "Second")]]]));
await vi.advanceTimersByTimeAsync(3_999);
expect(storage.writes).toBe(writesBeforeChange);
await vi.advanceTimersByTimeAsync(1);
expect(storage.writes).toBe(writesBeforeChange + 1);
cache.setHosts([]);
});
it("persists focused replica changes without writing transient stream head updates", async () => {
vi.useFakeTimers();
const storage = new MemoryStorage();
@@ -222,14 +251,14 @@ describe("ReplicaCache", () => {
useSessionStore
.getState()
.setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]]));
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(5_000);
expect(storage.writes).toBe(writesBeforeStream);
useSessionStore
.getState()
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]]));
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(5_000);
expect(storage.writes).toBe(writesBeforeStream + 1);
cache.setHosts([]);
+28 -11
View File
@@ -1,4 +1,5 @@
import { Buffer } from "buffer";
import { Keyboard } from "react-native";
import { z } from "zod";
import {
AgentStatusSchema,
@@ -23,7 +24,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 6;
const PERSIST_DELAY_MS = 750;
const PERSIST_AFTER_USER_INACTIVITY_MS = 5_000;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
const IsoDateSchema = z.iso.datetime();
@@ -805,17 +806,19 @@ export class ReplicaCache {
}
}
recordUserActivity(): void {
if (!this.persistTimer) return;
clearTimeout(this.persistTimer);
this.persistTimer = null;
this.schedulePersist();
}
start(): void {
if (this.unsubscribe) return;
const changedBeforeSubscription = this.captureSessions();
this.unsubscribe = useSessionStore.subscribe((state) => {
this.unsubscribe = useSessionStore.subscribe(() => {
if (this.activeServerIds.size === 0) return;
let changed = false;
for (const serverId of this.activeServerIds) {
const session = state.sessions[serverId];
if (session && this.captureHost(serverId, session)) changed = true;
}
if (changed) this.schedulePersist();
this.schedulePersist();
});
if (changedBeforeSubscription || this.needsPersist) this.schedulePersist();
}
@@ -856,15 +859,29 @@ export class ReplicaCache {
}
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint });
this.needsPersist = true;
this.schedulePersist();
}
async flush(): Promise<void> {
await this.persist(false);
}
private async flushPending(): Promise<void> {
if (Keyboard.isVisible()) {
this.schedulePersist();
return;
}
await this.persist(true);
}
private async persist(skipUnchanged: boolean): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
this.captureSessions();
const changed = this.captureSessions();
if (skipUnchanged && !changed && !this.needsPersist) return;
const bounded = this.buildBoundedPayload();
this.needsPersist = false;
if (!bounded) {
@@ -952,7 +969,7 @@ export class ReplicaCache {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
void this.flush();
}, PERSIST_DELAY_MS);
void this.flushPending();
}, PERSIST_AFTER_USER_INACTIVITY_MS);
}
}