diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index bbd751d4b..812231b9a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 ( diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 5c116a8c5..71a8bf347 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -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(); let gcScheduled = false; +const draftPersistStorage = createDraftPersistStorage( + createJSONStorage(() => AsyncStorage), +); + +export function flushDraftPersistStorage(): Promise { + return draftPersistStorage?.flush() ?? Promise.resolve(); +} function createDraftRecord(input: { draft: DraftInput; @@ -378,7 +386,7 @@ export const useDraftStore = create()( { name: "paseo-drafts", version: DRAFT_STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), + storage: draftPersistStorage, migrate: (persistedState) => { return migratePersistedState(persistedState, { migrateLegacyImages, diff --git a/packages/app/src/stores/draft-store/persistence.test.ts b/packages/app/src/stores/draft-store/persistence.test.ts new file mode 100644 index 000000000..bae660abc --- /dev/null +++ b/packages/app/src/stores/draft-store/persistence.test.ts @@ -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 | null = null; + let scheduled: { callback: () => void; dueAt: number } | null = null; + const storage: PersistStorage = { + 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"); + }); +}); diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/stores/draft-store/persistence.ts new file mode 100644 index 000000000..ab7c374c5 --- /dev/null +++ b/packages/app/src/stores/draft-store/persistence.ts @@ -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 extends PersistStorage { + flush: () => Promise; +} + +const systemScheduler: PersistenceScheduler = { + now: Date.now, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + cancel: (handle) => clearTimeout(handle as ReturnType), +}; + +export function createDraftPersistStorage( + storage: PersistStorage, + scheduler?: PersistenceScheduler, +): DraftPersistStorage; +export function createDraftPersistStorage( + storage: PersistStorage | undefined, + scheduler?: PersistenceScheduler, +): DraftPersistStorage | undefined; +export function createDraftPersistStorage( + storage: PersistStorage | undefined, + scheduler: PersistenceScheduler = systemScheduler, +): DraftPersistStorage | undefined { + if (!storage) { + return undefined; + } + + let pending: { name: string; value: Parameters[1] } | null = null; + let timer: unknown = null; + let lastWriteAt = -Infinity; + + const cancelTimer = () => { + if (timer !== null) { + scheduler.cancel(timer); + timer = null; + } + }; + const flush = async (): Promise => { + 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, + }; +}