From ec6cdabc6ea28f1a23bafab0b680601ee1430444 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 18 Jul 2026 03:52:38 +0800 Subject: [PATCH] perf: make native memo editing offline first --- apps/mobile/app.json | 4 +- apps/mobile/app/_layout.tsx | 34 ++- apps/mobile/package.json | 2 +- apps/mobile/src/lib/sync-queue.test.ts | 99 +++++++ apps/mobile/src/lib/sync-queue.ts | 173 +++++++++--- apps/mobile/src/screens/WorkspaceScreen.tsx | 295 ++++++++++++++++---- apps/mobile/tsconfig.json | 3 + bun.lock | 2 +- 8 files changed, 508 insertions(+), 104 deletions(-) create mode 100644 apps/mobile/src/lib/sync-queue.test.ts diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 1541fb52..30d24872 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "EdgeEver", "slug": "edgeever", "scheme": "edgeever", - "version": "0.3.1", + "version": "0.3.2", "orientation": "portrait", "userInterfaceStyle": "light", "newArchEnabled": true, @@ -13,7 +13,7 @@ }, "android": { "package": "org.edgeever.mobile", - "versionCode": 4, + "versionCode": 5, "adaptiveIcon": { "backgroundColor": "#f8fafc" } diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index a3059990..a6111b8d 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -15,6 +15,9 @@ import { SessionProvider } from "../src/lib/session"; void SplashScreen.preventAutoHideAsync(); const MOBILE_CACHE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; +const MOBILE_CACHE_STALE_TIME = 5 * 60 * 1000; +const MAX_PERSISTED_MEMO_LISTS = 12; +const MAX_PERSISTED_MEMO_DETAILS = 20; export default function RootLayout() { const [queryClient] = useState( @@ -26,7 +29,7 @@ export default function RootLayout() { networkMode: "offlineFirst", refetchOnReconnect: true, retry: 1, - staleTime: 30_000, + staleTime: MOBILE_CACHE_STALE_TIME, }, }, }) @@ -36,6 +39,26 @@ export default function RootLayout() { key: "edgeever.mobile.query-cache.v1", storage: AsyncStorage, throttleTime: 1_000, + serialize: (persistedClient) => { + const queries = persistedClient.clientState.queries; + const notebooks = queries.filter((query) => query.queryKey[1] === "notebooks"); + const memoLists = queries + .filter((query) => query.queryKey[1] === "memos") + .sort((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt) + .slice(0, MAX_PERSISTED_MEMO_LISTS); + const memoDetails = queries + .filter((query) => query.queryKey[1] === "memo") + .sort((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt) + .slice(0, MAX_PERSISTED_MEMO_DETAILS); + + return JSON.stringify({ + ...persistedClient, + clientState: { + ...persistedClient.clientState, + queries: [...notebooks, ...memoLists, ...memoDetails], + }, + }); + }, }) ); @@ -49,14 +72,9 @@ export default function RootLayout() { dehydrateOptions: { shouldDehydrateQuery: (query) => { const section = query.queryKey[1]; - const isDefaultMemoList = - section === "memos" && - query.queryKey[2] === "notebook" && - query.queryKey[3] === "all" && - query.queryKey[4] === "all" && - query.queryKey[5] === "updated-desc"; + const isOfflineReadableData = section === "notebooks" || section === "memos" || section === "memo"; - return query.state.status === "success" && (section === "notebooks" || isDefaultMemoList); + return query.state.status === "success" && isOfflineReadableData; }, }, maxAge: MOBILE_CACHE_MAX_AGE, diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b3eff2d6..9f3699bf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@edgeever/mobile", - "version": "0.3.1", + "version": "0.3.2", "private": true, "type": "module", "main": "expo-router/entry", diff --git a/apps/mobile/src/lib/sync-queue.test.ts b/apps/mobile/src/lib/sync-queue.test.ts new file mode 100644 index 00000000..b8b8c3ad --- /dev/null +++ b/apps/mobile/src/lib/sync-queue.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, expect, mock, test } from "bun:test"; +import type { MemoDetail } from "@edgeever/shared"; + +const storage = new Map(); + +mock.module("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: async (key: string) => storage.get(key) ?? null, + removeItem: async (key: string) => { + storage.delete(key); + }, + setItem: async (key: string, value: string) => { + storage.set(key, value); + }, + }, +})); + +const { listMobileSyncQueueItems, queueMobileMemoUpdate, syncMobileQueuedChanges } = await import("./sync-queue"); + +const basePayload = { + memoId: "memo-1", + expectedRevision: 1, + expectedContentHash: "hash-1", + title: "First", + contentMarkdown: "first", + notebookId: "notebook-1", + tags: [], +}; + +beforeEach(() => { + storage.clear(); +}); + +test("keeps pending updates isolated by instance", async () => { + await queueMobileMemoUpdate("https://one.example", basePayload); + await queueMobileMemoUpdate("https://two.example", { ...basePayload, title: "Second instance" }); + + expect((await listMobileSyncQueueItems("https://one.example"))[0]?.payload.title).toBe("First"); + expect((await listMobileSyncQueueItems("https://two.example"))[0]?.payload.title).toBe("Second instance"); +}); + +test("rebases a newer local save when an older save finishes syncing", async () => { + await queueMobileMemoUpdate("https://one.example", basePayload); + + let markUpdateStarted: (() => void) | undefined; + const updateStarted = new Promise((resolve) => { + markUpdateStarted = resolve; + }); + let resolveUpdate: ((memo: MemoDetail) => void) | undefined; + const updateResponse = new Promise((resolve) => { + resolveUpdate = resolve; + }); + const syncedMemo = createMemo({ revision: 2, contentHash: "hash-2", contentMarkdown: "first" }); + const client = { + createMemoEditSession: async () => ({ editSession: { id: "edit-1", baseRevision: 1, baseContentHash: "hash-1" } }), + updateMemo: async () => { + markUpdateStarted?.(); + return updateResponse.then((memo) => ({ memo })); + }, + }; + + const firstSync = syncMobileQueuedChanges(client as never, "https://one.example"); + await updateStarted; + await queueMobileMemoUpdate("https://one.example", { + ...basePayload, + title: "Newest", + contentMarkdown: "newest", + }); + resolveUpdate?.(syncedMemo); + await firstSync; + + const queued = (await listMobileSyncQueueItems("https://one.example"))[0]; + expect(queued?.payload.title).toBe("Newest"); + expect(queued?.payload.expectedRevision).toBe(2); + expect(queued?.payload.expectedContentHash).toBe("hash-2"); +}); + +const createMemo = (overrides: Partial = {}): MemoDetail => ({ + id: "memo-1", + notebookId: "notebook-1", + title: "First", + excerpt: "first", + tags: [], + isPinned: false, + isArchived: false, + isDeleted: false, + revision: 1, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + contentJson: { type: "doc", content: [] }, + contentMarkdown: "first", + contentText: "first", + contentHash: "hash-1", + sourceMemoIds: [], + mergeSourceCount: 0, + mergedIntoMemoId: null, + ...overrides, +}); diff --git a/apps/mobile/src/lib/sync-queue.ts b/apps/mobile/src/lib/sync-queue.ts index 5bdb6ea4..84f84aca 100644 --- a/apps/mobile/src/lib/sync-queue.ts +++ b/apps/mobile/src/lib/sync-queue.ts @@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { ApiRequestError, type createEdgeEverClient } from "@edgeever/client"; import type { MemoDetail } from "@edgeever/shared"; -const SYNC_QUEUE_KEY = "edgeever.mobile.syncQueue.v1"; +const LEGACY_SYNC_QUEUE_KEY = "edgeever.mobile.syncQueue.v1"; +const SYNC_QUEUE_KEY_PREFIX = "edgeever.mobile.syncQueue.v2"; export type MobileMemoUpdateSyncPayload = { memoId: string; @@ -25,8 +26,11 @@ export type MobileSyncQueueItem = { nextAttemptAt: string | null; createdAt: string; updatedAt: string; + version: number; }; +let syncQueueWriteChain: Promise = Promise.resolve(); + export type MobileSyncQueueSummary = { total: number; pending: number; @@ -52,40 +56,57 @@ export const emptyMobileSyncQueueSummary = (): MobileSyncQueueSummary => ({ export const getMobileMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`; -export const queueMobileMemoUpdate = async (payload: MobileMemoUpdateSyncPayload) => { +export const queueMobileMemoUpdate = async (scope: string, payload: MobileMemoUpdateSyncPayload) => { const now = new Date().toISOString(); - const items = await readMobileSyncQueue(); const id = getMobileMemoUpdateQueueId(payload.memoId); - const existing = items.find((item) => item.id === id); - const nextItem: MobileSyncQueueItem = { - id, - kind: "memo.update", - memoId: payload.memoId, - status: "pending", - payload, - attemptCount: existing?.attemptCount ?? 0, - lastError: null, - nextAttemptAt: null, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - }; + await mutateMobileSyncQueue(scope, (items) => { + const existing = items.find((item) => item.id === id); + const nextItem: MobileSyncQueueItem = { + id, + kind: "memo.update", + memoId: payload.memoId, + status: "pending", + payload, + attemptCount: existing?.attemptCount ?? 0, + lastError: null, + nextAttemptAt: null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + version: (existing?.version ?? 0) + 1, + }; - await writeMobileSyncQueue([nextItem, ...items.filter((item) => item.id !== id)]); - return summarizeMobileSyncQueue(await readMobileSyncQueue()); + return [nextItem, ...items.filter((item) => item.id !== id)]; + }); + return summarizeMobileSyncQueue(await readMobileSyncQueue(scope)); }; -export const loadMobileSyncQueueSummary = async () => summarizeMobileSyncQueue(await readMobileSyncQueue()); +export const loadMobileSyncQueueSummary = async (scope: string) => summarizeMobileSyncQueue(await readMobileSyncQueue(scope)); -export const listMobileSyncQueueItems = async () => - (await readMobileSyncQueue()).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +export const getMobileSyncRetryDelay = async (scope: string) => { + const now = Date.now(); + const retryTimes = (await readMobileSyncQueue(scope)) + .filter((item) => item.status === "pending" || item.status === "error" || item.status === "syncing") + .map((item) => (item.nextAttemptAt ? Date.parse(item.nextAttemptAt) : now)) + .filter(Number.isFinite); -export const deleteMobileSyncQueueItem = async (id: string) => { - await removeMobileSyncQueueItem(id); - return loadMobileSyncQueueSummary(); + if (retryTimes.length === 0) { + return null; + } + + return Math.max(250, Math.min(...retryTimes) - now); +}; + +export const listMobileSyncQueueItems = async (scope: string) => + (await readMobileSyncQueue(scope)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); + +export const deleteMobileSyncQueueItem = async (scope: string, id: string) => { + await removeMobileSyncQueueItem(scope, id); + return loadMobileSyncQueueSummary(scope); }; export const syncMobileQueuedChanges = async ( client: ReturnType, + scope: string, options: { onSynced?: (memo: MemoDetail) => void | Promise; } = {} @@ -97,34 +118,40 @@ export const syncMobileQueuedChanges = async ( conflicted: 0, }; const now = new Date(); - const items = (await readMobileSyncQueue()) + const items = (await readMobileSyncQueue(scope)) .filter((item) => item.status === "pending" || item.status === "error" || item.status === "syncing") .filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now) .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); for (const item of items) { + const itemVersion = item.version ?? 1; result.attempted += 1; - await updateMobileSyncQueueItem(item.id, { + await updateMobileSyncQueueItem(scope, item.id, { status: "syncing", updatedAt: new Date().toISOString(), - }); + }, itemVersion); try { const memo = await syncMobileQueueItem(client, item); - await removeMobileSyncQueueItem(item.id); - await options.onSynced?.(memo); + const removed = await removeMobileSyncQueueItem(scope, item.id, itemVersion); + + if (removed) { + await options.onSynced?.(memo); + } else { + await rebaseQueuedMemoUpdate(scope, item.id, itemVersion, memo); + } result.synced += 1; } catch (error) { const status = isRevisionConflict(error) ? "conflict" : "error"; const attemptCount = item.attemptCount + 1; - await updateMobileSyncQueueItem(item.id, { + await updateMobileSyncQueueItem(scope, item.id, { status, attemptCount, lastError: getErrorMessage(error), nextAttemptAt: status === "error" ? nextRetryAt(attemptCount) : null, updatedAt: new Date().toISOString(), - }); + }, itemVersion); if (status === "conflict") { result.conflicted += 1; @@ -168,8 +195,18 @@ const syncMobileQueueItem = async (client: ReturnType => { - const rawValue = await AsyncStorage.getItem(SYNC_QUEUE_KEY); +const readMobileSyncQueue = async (scope: string): Promise => { + const storageKey = getSyncQueueStorageKey(scope); + let rawValue = await AsyncStorage.getItem(storageKey); + + if (!rawValue) { + const legacyValue = await AsyncStorage.getItem(LEGACY_SYNC_QUEUE_KEY); + if (legacyValue) { + rawValue = legacyValue; + await AsyncStorage.setItem(storageKey, legacyValue); + await AsyncStorage.removeItem(LEGACY_SYNC_QUEUE_KEY); + } + } if (!rawValue) { return []; @@ -183,18 +220,74 @@ const readMobileSyncQueue = async (): Promise => { } }; -const writeMobileSyncQueue = (items: MobileSyncQueueItem[]) => AsyncStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(items)); +const writeMobileSyncQueue = (scope: string, items: MobileSyncQueueItem[]) => + AsyncStorage.setItem(getSyncQueueStorageKey(scope), JSON.stringify(items)); -const updateMobileSyncQueueItem = async (id: string, patch: Partial) => { - const items = await readMobileSyncQueue(); - await writeMobileSyncQueue(items.map((item) => (item.id === id ? { ...item, ...patch } : item))); +const updateMobileSyncQueueItem = async (scope: string, id: string, patch: Partial, expectedVersion?: number) => { + let updated = false; + await mutateMobileSyncQueue(scope, (items) => + items.map((item) => { + if (item.id !== id || (expectedVersion !== undefined && (item.version ?? 1) !== expectedVersion)) { + return item; + } + + updated = true; + return { ...item, ...patch }; + }) + ); + return updated; }; -const removeMobileSyncQueueItem = async (id: string) => { - const items = await readMobileSyncQueue(); - await writeMobileSyncQueue(items.filter((item) => item.id !== id)); +const removeMobileSyncQueueItem = async (scope: string, id: string, expectedVersion?: number) => { + let removed = false; + await mutateMobileSyncQueue(scope, (items) => + items.filter((item) => { + if (item.id !== id || (expectedVersion !== undefined && (item.version ?? 1) !== expectedVersion)) { + return true; + } + + removed = true; + return false; + }) + ); + return removed; }; +const rebaseQueuedMemoUpdate = async (scope: string, id: string, syncedVersion: number, memo: MemoDetail) => { + await mutateMobileSyncQueue(scope, (items) => + items.map((item) => { + if (item.id !== id || (item.version ?? 1) <= syncedVersion) { + return item; + } + + return { + ...item, + payload: { + ...item.payload, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + }, + status: "pending", + lastError: null, + nextAttemptAt: null, + updatedAt: new Date().toISOString(), + }; + }) + ); +}; + +const mutateMobileSyncQueue = async (scope: string, mutate: (items: MobileSyncQueueItem[]) => MobileSyncQueueItem[]) => { + const operation = syncQueueWriteChain.then(async () => { + const items = await readMobileSyncQueue(scope); + await writeMobileSyncQueue(scope, mutate(items)); + }); + + syncQueueWriteChain = operation.catch(() => undefined); + await operation; +}; + +const getSyncQueueStorageKey = (scope: string) => `${SYNC_QUEUE_KEY_PREFIX}:${encodeURIComponent(scope.trim().toLowerCase())}`; + const summarizeMobileSyncQueue = (items: MobileSyncQueueItem[]) => items.reduce((summary, item) => { summary.total += 1; diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index b82bbdf1..d96ef950 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -1,5 +1,5 @@ import { createContext, lazy, memo, Suspense, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { keepPreviousData, useMutation, useQuery, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQuery, useQueryClient, type QueryClient, type UseMutationResult } from "@tanstack/react-query"; import * as Clipboard from "expo-clipboard"; import Constants from "expo-constants"; import type { DocumentPickerAsset } from "expo-document-picker"; @@ -95,10 +95,10 @@ import { useSession } from "../lib/session"; import { deleteMobileSyncQueueItem, emptyMobileSyncQueueSummary, + getMobileSyncRetryDelay, listMobileSyncQueueItems, loadMobileSyncQueueSummary, queueMobileMemoUpdate, - shouldQueueMobileMemoSaveError, syncMobileQueuedChanges, type MobileSyncQueueItem, type MobileSyncQueueSummary, @@ -297,6 +297,7 @@ type MarkdownAction = "heading" | "bold" | "italic" | "bullet" | "checklist" | " export const WorkspaceScreen = () => { const { client, session, signOut } = useSession(); const queryClient = useQueryClient(); + const syncQueueScope = session?.baseUrl ?? ""; const [activeView, setActiveView] = useState("notes"); const [activeNotebookId, setActiveNotebookId] = useState(ALL_NOTES_ID); const [memoView, setMemoView] = useState("notebook"); @@ -331,8 +332,9 @@ export const WorkspaceScreen = () => { const [syncQueueSummary, setSyncQueueSummary] = useState(() => emptyMobileSyncQueueSummary()); const [syncQueueMessage, setSyncQueueMessage] = useState(""); const [isSyncingQueue, setIsSyncingQueue] = useState(false); - const [lastAutoSyncAt, setLastAutoSyncAt] = useState(null); const autoSyncRunningRef = useRef(false); + const autoSyncRequestedRef = useRef(false); + const autoSyncRetryTimerRef = useRef | null>(null); const debouncedSearchText = useDebouncedValue(searchText.trim(), 250); const notebooksQuery = useQuery({ @@ -417,7 +419,7 @@ export const WorkspaceScreen = () => { queryClient.invalidateQueries({ queryKey: ["mobile", "search"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "memo"] }), ]); - setSyncQueueSummary(await loadMobileSyncQueueSummary()); + setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope)); }; const handleMemoPress = (memoId: string) => { @@ -522,16 +524,25 @@ export const WorkspaceScreen = () => { useEffect(() => { let mounted = true; - loadMobileSyncQueueSummary().then((summary) => { - if (mounted) { - setSyncQueueSummary(summary); + Promise.all([loadMobileSyncQueueSummary(syncQueueScope), listMobileSyncQueueItems(syncQueueScope)]).then(([summary, items]) => { + if (!mounted) { + return; + } + + setSyncQueueSummary(summary); + for (const item of items) { + const cachedMemo = findCachedMemoDetail(queryClient, item.memoId); + if (cachedMemo) { + const optimisticMemo = createOptimisticMemo(cachedMemo, item.payload); + applyOptimisticMemoToCache(queryClient, cachedMemo, { ...optimisticMemo, updatedAt: item.updatedAt }); + } } }); return () => { mounted = false; }; - }, []); + }, [queryClient, syncQueueScope]); useEffect(() => { let mounted = true; @@ -646,6 +657,30 @@ export const WorkspaceScreen = () => { }, }); + const localUpdateMemoMutation = useMutation({ + mutationFn: async ({ memo, payload }: { memo: MemoDetail; payload: { title?: string; contentMarkdown?: string; isPinned?: boolean; notebookId?: string; tags?: string[] } }) => { + const optimisticMemo = createOptimisticMemo(memo, payload); + + await queueMobileMemoUpdate(syncQueueScope, { + memoId: memo.id, + expectedRevision: memo.revision, + expectedContentHash: memo.contentHash, + title: optimisticMemo.title?.trim() || DEFAULT_MEMO_TITLE, + contentMarkdown: optimisticMemo.contentMarkdown, + notebookId: optimisticMemo.notebookId, + tags: optimisticMemo.tags, + }); + + return optimisticMemo; + }, + onSuccess: async (memo, variables) => { + applyOptimisticMemoToCache(queryClient, variables.memo, memo); + setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope)); + setSyncQueueMessage("已保存到本地,正在后台同步"); + void runAutomaticSync(); + }, + }); + const deleteMemoMutation = useMutation({ mutationFn: async ({ memo, permanent }: { memo: MemoDetail; permanent: boolean }) => { if (!client) { @@ -852,17 +887,20 @@ export const WorkspaceScreen = () => { setSyncQueueMessage(""); try { - const result = await syncMobileQueuedChanges(client, { + const result = await syncMobileQueuedChanges(client, syncQueueScope, { onSynced: async (memo) => { queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo }); queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo }); }, }); - await invalidateWorkspace(); - setSyncQueueSummary(await loadMobileSyncQueueSummary()); + const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope); + if (nextSummary.total === 0) { + await invalidateWorkspace(); + } + setSyncQueueSummary(nextSummary); setSyncQueueMessage(result.attempted === 0 ? "没有可同步的变更" : `已同步 ${result.synced} 条,失败 ${result.failed + result.conflicted} 条`); } catch (error) { - setSyncQueueSummary(await loadMobileSyncQueueSummary()); + setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope)); setSyncQueueMessage(error instanceof Error ? error.message : "同步失败"); } finally { setIsSyncingQueue(false); @@ -870,13 +908,23 @@ export const WorkspaceScreen = () => { }; const runAutomaticSync = async () => { - if (!client || autoSyncRunningRef.current) { + if (!client) { return; } + if (autoSyncRunningRef.current) { + autoSyncRequestedRef.current = true; + return; + } + + if (autoSyncRetryTimerRef.current) { + clearTimeout(autoSyncRetryTimerRef.current); + autoSyncRetryTimerRef.current = null; + } + autoSyncRunningRef.current = true; - const summary = await loadMobileSyncQueueSummary(); + const summary = await loadMobileSyncQueueSummary(syncQueueScope); setSyncQueueSummary(summary); if (summary.pending + summary.error + summary.syncing === 0) { @@ -888,22 +936,36 @@ export const WorkspaceScreen = () => { setSyncQueueMessage("正在自动同步本地变更"); try { - const result = await syncMobileQueuedChanges(client, { + const result = await syncMobileQueuedChanges(client, syncQueueScope, { onSynced: async (memo) => { queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo }); queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo }); }, }); - await invalidateWorkspace(); - setSyncQueueSummary(await loadMobileSyncQueueSummary()); - setLastAutoSyncAt(new Date().toISOString()); + const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope); + if (nextSummary.total === 0) { + await invalidateWorkspace(); + } + setSyncQueueSummary(nextSummary); setSyncQueueMessage(result.attempted === 0 ? "" : `自动同步完成:成功 ${result.synced} 条,失败 ${result.failed + result.conflicted} 条`); } catch (error) { - setSyncQueueSummary(await loadMobileSyncQueueSummary()); + setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope)); setSyncQueueMessage(error instanceof Error ? error.message : "自动同步失败"); } finally { setIsSyncingQueue(false); autoSyncRunningRef.current = false; + if (autoSyncRequestedRef.current) { + autoSyncRequestedRef.current = false; + void runAutomaticSync(); + } else { + const retryDelay = await getMobileSyncRetryDelay(syncQueueScope); + if (retryDelay !== null) { + autoSyncRetryTimerRef.current = setTimeout(() => { + autoSyncRetryTimerRef.current = null; + void runAutomaticSync(); + }, retryDelay); + } + } } }; @@ -920,8 +982,14 @@ export const WorkspaceScreen = () => { } }); - return () => subscription.remove(); - }, [client, lastAutoSyncAt]); + return () => { + subscription.remove(); + if (autoSyncRetryTimerRef.current) { + clearTimeout(autoSyncRetryTimerRef.current); + autoSyncRetryTimerRef.current = null; + } + }; + }, [client, syncQueueScope]); return ( @@ -1035,16 +1103,11 @@ export const WorkspaceScreen = () => { imageCompressionEnabled={imageCompressionEnabled} notebooks={notebooks} onClose={() => setEditingMemo(null)} - onQueued={async () => { - setEditingMemo(null); - setSyncQueueSummary(await loadMobileSyncQueueSummary()); - setSyncQueueMessage("变更已保存到本地队列"); - }} onSaved={(memo) => { setEditingMemo(null); setSelectedMemoId(memo.id); }} - updateMutation={updateMemoMutation} + updateMutation={localUpdateMemoMutation} /> : null} {richEditingMemo ? : null} {notebookPickerOpen ? { {advancedPlayOpen ? setAdvancedPlayOpen(false)} visible /> : null} {syncQueueOpen ? setSyncQueueOpen(false)} - onChanged={async () => setSyncQueueSummary(await loadMobileSyncQueueSummary())} + onChanged={async () => setSyncQueueSummary(await loadMobileSyncQueueSummary(syncQueueScope))} onSync={handleSyncQueuedChanges} + scope={syncQueueScope} visible /> : null} {systemInfoOpen ? setSystemInfoOpen(false)} visible /> : null} @@ -3018,11 +3082,13 @@ const SyncQueueModal = ({ onChanged, onClose, onSync, + scope, visible, }: { onChanged: () => void | Promise; onClose: () => void; onSync: () => void | Promise; + scope: string; visible: boolean; }) => { const [items, setItems] = useState([]); @@ -3032,7 +3098,7 @@ const SyncQueueModal = ({ const refreshItems = async () => { setLoading(true); try { - setItems(await listMobileSyncQueueItems()); + setItems(await listMobileSyncQueueItems(scope)); } finally { setLoading(false); } @@ -3042,7 +3108,7 @@ const SyncQueueModal = ({ if (visible) { void refreshItems(); } - }, [visible]); + }, [scope, visible]); const discardItem = (item: MobileSyncQueueItem) => { Alert.alert("丢弃本地变更?", "此操作会移除这条待同步记录,不会修改服务端笔记。", [ @@ -3051,7 +3117,7 @@ const SyncQueueModal = ({ text: "丢弃", style: "destructive", onPress: async () => { - await deleteMobileSyncQueueItem(item.id); + await deleteMobileSyncQueueItem(scope, item.id); await onChanged(); await refreshItems(); }, @@ -4128,7 +4194,6 @@ const EditMemoModal = ({ memo, notebooks, onClose, - onQueued, onSaved, updateMutation, }: { @@ -4136,7 +4201,6 @@ const EditMemoModal = ({ memo: MemoDetail | null; notebooks: Notebook[]; onClose: () => void; - onQueued: () => void | Promise; onSaved: (memo: MemoDetail) => void; updateMutation: UseMutationResult< MemoDetail, @@ -4334,25 +4398,6 @@ const EditMemoModal = ({ setLoadedDraftUpdatedAt(null); onSaved(savedMemo); }, - onError: async (error) => { - if (!shouldQueueMobileMemoSaveError(error)) { - return; - } - - await queueMobileMemoUpdate({ - memoId: memo.id, - expectedRevision: memo.revision, - expectedContentHash: memo.contentHash, - title: title.trim() || DEFAULT_MEMO_TITLE, - contentMarkdown, - notebookId, - tags: parseTags(tagsText), - }); - await clearMobileMemoDraft(memo.id); - await onQueued(); - updateMutation.reset(); - Alert.alert("已保存到本地队列", "网络恢复后可在设置页手动同步。"); - }, } ); }; @@ -5574,6 +5619,152 @@ const prepareUploadAsset = async ( } }; +const createOptimisticMemo = ( + memo: MemoDetail, + payload: { + title?: string; + contentMarkdown?: string; + isPinned?: boolean; + notebookId?: string; + tags?: string[]; + } +): MemoDetail => { + const contentMarkdown = payload.contentMarkdown ?? memo.contentMarkdown; + const contentText = markdownToLocalText(contentMarkdown); + + return { + ...memo, + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.isPinned !== undefined ? { isPinned: payload.isPinned } : {}), + ...(payload.notebookId !== undefined ? { notebookId: payload.notebookId } : {}), + ...(payload.tags !== undefined ? { tags: payload.tags } : {}), + ...(payload.contentMarkdown !== undefined + ? { + contentMarkdown, + contentText, + excerpt: contentText.slice(0, 180), + } + : {}), + updatedAt: new Date().toISOString(), + }; +}; + +const applyOptimisticMemoToCache = (queryClient: QueryClient, previousMemo: MemoDetail, nextMemo: MemoDetail) => { + const detailQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "memo"] }); + + for (const query of detailQueries) { + const data = query.state.data as { memo?: MemoDetail } | undefined; + if (data?.memo?.id === nextMemo.id) { + queryClient.setQueryData(query.queryKey, { ...data, memo: nextMemo }); + } + } + + const listQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "memos"] }); + + for (const query of listQueries) { + const data = query.state.data as { memos?: MemoSummary[]; totalCount?: number; nextCursor?: string | null } | undefined; + if (!data?.memos) { + continue; + } + + const previouslyMatched = memoMatchesListQuery(previousMemo, query.queryKey); + const nextMatches = memoMatchesListQuery(nextMemo, query.queryKey); + const withoutMemo = data.memos.filter((memo) => memo.id !== nextMemo.id); + const memos = nextMatches ? sortMemoSummaries([nextMemo, ...withoutMemo], query.queryKey[5]).slice(0, 50) : withoutMemo; + const totalCount = Math.max(0, (data.totalCount ?? data.memos.length) + (nextMatches ? 1 : 0) - (previouslyMatched ? 1 : 0)); + + queryClient.setQueryData(query.queryKey, { ...data, memos, totalCount }); + } + + const searchQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "search"] }); + for (const query of searchQueries) { + const data = query.state.data as { memos?: MemoSummary[] } | undefined; + if (data?.memos?.some((memo) => memo.id === nextMemo.id)) { + queryClient.setQueryData(query.queryKey, { + ...data, + memos: data.memos.map((memo) => (memo.id === nextMemo.id ? nextMemo : memo)), + }); + } + } + + if (previousMemo.notebookId !== nextMemo.notebookId) { + queryClient.setQueriesData<{ notebooks: Notebook[] }>({ queryKey: ["mobile", "notebooks"] }, (data) => { + if (!data) { + return data; + } + + return { + ...data, + notebooks: data.notebooks.map((notebook) => { + if (notebook.id === previousMemo.notebookId) { + return { ...notebook, memoCount: Math.max(0, notebook.memoCount - 1) }; + } + if (notebook.id === nextMemo.notebookId) { + return { ...notebook, memoCount: notebook.memoCount + 1, lastMemoUpdatedAt: nextMemo.updatedAt }; + } + return notebook; + }), + }; + }); + } +}; + +const findCachedMemoDetail = (queryClient: QueryClient, memoId: string) => { + const detailQueries = queryClient.getQueryCache().findAll({ queryKey: ["mobile", "memo"] }); + + for (const query of detailQueries) { + const data = query.state.data as { memo?: MemoDetail } | undefined; + if (data?.memo?.id === memoId) { + return data.memo; + } + } + + return null; +}; + +const memoMatchesListQuery = (memo: MemoSummary, queryKey: readonly unknown[]) => { + const view = queryKey[2]; + const notebookId = queryKey[3]; + const filter = queryKey[4]; + + if ((view === "trash") !== memo.isDeleted) { + return false; + } + if (notebookId !== ALL_NOTES_ID && notebookId !== memo.notebookId) { + return false; + } + if (filter === "tagged" && memo.tags.length === 0) { + return false; + } + if (filter === "untagged" && memo.tags.length > 0) { + return false; + } + if (filter === "pinned" && !memo.isPinned) { + return false; + } + + return true; +}; + +const sortMemoSummaries = (memos: MemoSummary[], sortMode: unknown) => + [...memos].sort((left, right) => { + if (sortMode === "title-asc") { + return (left.title || DEFAULT_MEMO_TITLE).localeCompare(right.title || DEFAULT_MEMO_TITLE); + } + if (sortMode === "created-desc") { + return right.createdAt.localeCompare(left.createdAt); + } + return right.updatedAt.localeCompare(left.updatedAt); + }); + +const markdownToLocalText = (markdown: string) => + markdown + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/[`*_>#~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const getCompressedImageSize = (width: number, height: number) => { if (width >= height) { return { width: MAX_COMPRESSED_IMAGE_EDGE }; diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 39978f15..d267c3a1 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -17,5 +17,8 @@ "src/**/*.tsx", "expo-env.d.ts", ".expo/types/**/*.ts" + ], + "exclude": [ + "src/**/*.test.ts" ] } diff --git a/bun.lock b/bun.lock index 46ec6454..de0d387b 100644 --- a/bun.lock +++ b/bun.lock @@ -79,7 +79,7 @@ }, "apps/mobile": { "name": "@edgeever/mobile", - "version": "0.3.1", + "version": "0.3.2", "dependencies": { "@edgeever/client": "workspace:*", "@edgeever/shared": "workspace:*",