mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
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:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user