Reduce composer overhead while typing (#2086)

* perf(app): reduce composer draft writes

Persist draft checkpoints every 200 ms during sustained typing while keeping the latest in-memory draft current. A trailing write stores the newest checkpoint when typing stops.

* test(app): cover draft persistence checkpoints

Keep the throttle policy deterministic at its storage boundary, including latest-value coalescing and pending-write cancellation when drafts are cleared.

* fix(app): flush pending drafts on background

Flush the latest throttled checkpoint when the app leaves the foreground and report asynchronous persistence failures. Extend the storage-boundary tests across consecutive intervals and explicit lifecycle flushing.
This commit is contained in:
Mohamed Boudra
2026-07-15 15:40:11 +02:00
committed by GitHub
parent 319d9017c2
commit 7f011d16c5
4 changed files with 210 additions and 2 deletions

View File

@@ -16,7 +16,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { useWindowDimensions, View } from "react-native";
import { AppState, useWindowDimensions, View } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
@@ -97,6 +97,7 @@ import {
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
@@ -959,6 +960,14 @@ function RootAppTree() {
export default function RootLayout() {
useEffect(() => installWebScrollbarStyles(), []);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState !== "active") {
void flushDraftPersistStorage();
}
});
return () => subscription.remove();
}, []);
return (
<QueryProvider>

View File

@@ -26,6 +26,7 @@ import {
type DraftStoreState,
} from "./state";
import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration";
import { createDraftPersistStorage } from "./persistence";
export type { DraftInput, DraftLifecycleState } from "./state";
@@ -49,6 +50,13 @@ type DraftStore = DraftStoreState & DraftStoreActions;
const draftGenerations = new Map<string, number>();
let gcScheduled = false;
const draftPersistStorage = createDraftPersistStorage(
createJSONStorage<DraftStoreState>(() => AsyncStorage),
);
export function flushDraftPersistStorage(): Promise<void> {
return draftPersistStorage?.flush() ?? Promise.resolve();
}
function createDraftRecord(input: {
draft: DraftInput;
@@ -378,7 +386,7 @@ export const useDraftStore = create<DraftStore>()(
{
name: "paseo-drafts",
version: DRAFT_STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
storage: draftPersistStorage,
migrate: (persistedState) => {
return migratePersistedState(persistedState, {
migrateLegacyImages,

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import type { PersistStorage, StorageValue } from "zustand/middleware";
import {
createDraftPersistStorage,
DRAFT_PERSIST_INTERVAL_MS,
type PersistenceScheduler,
} from "./persistence";
interface DraftState {
text: string;
}
function createDraftPersistence() {
let nowMs = 0;
let saved: StorageValue<DraftState> | null = null;
let scheduled: { callback: () => void; dueAt: number } | null = null;
const storage: PersistStorage<DraftState> = {
getItem: () => saved,
setItem: (_name, value) => {
saved = value;
},
removeItem: () => {
saved = null;
},
};
const scheduler: PersistenceScheduler = {
now: () => nowMs,
schedule: (callback, delayMs) => (scheduled = { callback, dueAt: nowMs + delayMs }),
cancel: () => {
scheduled = null;
},
};
const drafts = createDraftPersistStorage(storage, scheduler);
return {
save(text: string) {
drafts.setItem("drafts", { state: { text } });
},
remove() {
drafts.removeItem("drafts");
},
flush() {
return drafts.flush();
},
advance(ms: number) {
nowMs += ms;
if (scheduled && scheduled.dueAt <= nowMs) {
const { callback } = scheduled;
scheduled = null;
callback();
}
},
text() {
return saved?.state.text ?? null;
},
};
}
describe("draft persistence", () => {
it("checkpoints the first change and the latest change in each interval", () => {
const drafts = createDraftPersistence();
drafts.save("a");
drafts.save("ab");
drafts.save("abc");
expect(drafts.text()).toBe("a");
drafts.advance(DRAFT_PERSIST_INTERVAL_MS - 1);
expect(drafts.text()).toBe("a");
drafts.advance(1);
expect(drafts.text()).toBe("abc");
});
it("does not restore a pending draft after storage is cleared", () => {
const drafts = createDraftPersistence();
drafts.save("first checkpoint");
drafts.save("pending checkpoint");
drafts.remove();
drafts.advance(DRAFT_PERSIST_INTERVAL_MS);
expect(drafts.text()).toBeNull();
});
it("continues checkpointing the latest change across consecutive intervals", () => {
const drafts = createDraftPersistence();
drafts.save("first");
drafts.save("first interval");
drafts.advance(DRAFT_PERSIST_INTERVAL_MS);
expect(drafts.text()).toBe("first interval");
drafts.save("second");
drafts.save("second interval");
drafts.advance(DRAFT_PERSIST_INTERVAL_MS);
expect(drafts.text()).toBe("second interval");
});
it("flushes the latest pending change before the interval ends", async () => {
const drafts = createDraftPersistence();
drafts.save("first checkpoint");
drafts.save("pending checkpoint");
await drafts.flush();
expect(drafts.text()).toBe("pending checkpoint");
});
});

View File

@@ -0,0 +1,82 @@
import type { PersistStorage } from "zustand/middleware";
export const DRAFT_PERSIST_INTERVAL_MS = 200;
export interface PersistenceScheduler {
now: () => number;
schedule: (callback: () => void, delayMs: number) => unknown;
cancel: (handle: unknown) => void;
}
export interface DraftPersistStorage<T> extends PersistStorage<T> {
flush: () => Promise<void>;
}
const systemScheduler: PersistenceScheduler = {
now: Date.now,
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
};
export function createDraftPersistStorage<T>(
storage: PersistStorage<T>,
scheduler?: PersistenceScheduler,
): DraftPersistStorage<T>;
export function createDraftPersistStorage<T>(
storage: PersistStorage<T> | undefined,
scheduler?: PersistenceScheduler,
): DraftPersistStorage<T> | undefined;
export function createDraftPersistStorage<T>(
storage: PersistStorage<T> | undefined,
scheduler: PersistenceScheduler = systemScheduler,
): DraftPersistStorage<T> | undefined {
if (!storage) {
return undefined;
}
let pending: { name: string; value: Parameters<typeof storage.setItem>[1] } | null = null;
let timer: unknown = null;
let lastWriteAt = -Infinity;
const cancelTimer = () => {
if (timer !== null) {
scheduler.cancel(timer);
timer = null;
}
};
const flush = async (): Promise<void> => {
cancelTimer();
const write = pending;
pending = null;
if (!write) {
return;
}
lastWriteAt = scheduler.now();
try {
await storage.setItem(write.name, write.value);
} catch (error) {
console.warn("[DraftStore] Failed to persist draft checkpoint", error);
}
};
return {
getItem: (name) => storage.getItem(name),
setItem: (name, value) => {
pending = { name, value };
const delay = DRAFT_PERSIST_INTERVAL_MS - (scheduler.now() - lastWriteAt);
if (delay <= 0) {
return flush();
}
timer ??= scheduler.schedule(() => {
void flush();
}, delay);
},
removeItem: (name) => {
cancelTimer();
pending = null;
lastWriteAt = scheduler.now();
return storage.removeItem(name);
},
flush,
};
}