mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
109
packages/app/src/stores/draft-store/persistence.test.ts
Normal file
109
packages/app/src/stores/draft-store/persistence.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
82
packages/app/src/stores/draft-store/persistence.ts
Normal file
82
packages/app/src/stores/draft-store/persistence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user