fix(mobile): push note edits online-first and unstick sync queue

Android edits could stay pending after pull-to-refresh because content always queued and refresh only pulled. Save online when reachable, force-flush the outbox on refresh and resume, and surface retry/error details so Web sees Android changes again.

Related Issue: #185
This commit is contained in:
tianma-if committed 2026-08-06 21:05:57 +08:00
1 parent 95db3ee5a3
commit b4e8be688b
7 files changed
+448 -58

No files matched your search

+46 -24
View File
@@ -29,8 +29,9 @@ export const useMobileAutomaticSync = ({
const [syncQueueItems, setSyncQueueItems] = useState<MobileSyncQueueItem[]>([]);
const runningRef = useRef(false);
const requestedRef = useRef(false);
const requestedForceRef = useRef(false);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const runRef = useRef<() => Promise<void>>(async () => undefined);
const runRef = useRef<(force?: boolean) => Promise<void>>(async () => undefined);
const refreshSyncQueueItems = useCallback(async () => {
const items = await listMobileSyncQueueItems(syncQueueScope);
@@ -47,13 +48,14 @@ export const useMobileAutomaticSync = ({
]);
}, [queryClient]);
const runAutomaticSync = useCallback(async () => {
const runAutomaticSync = useCallback(async (force = false) => {
if (!client) {
return;
}
if (runningRef.current) {
requestedRef.current = true;
requestedForceRef.current = requestedForceRef.current || force;
return;
}
@@ -63,29 +65,41 @@ export const useMobileAutomaticSync = ({
}
runningRef.current = true;
let shouldForce = force;
try {
const summary = await loadMobileSyncQueueSummary(syncQueueScope);
if (summary.pending + summary.error + summary.syncing === 0) {
return;
}
for (;;) {
const summary = await loadMobileSyncQueueSummary(syncQueueScope);
const retryable = summary.pending + summary.error + summary.syncing;
await syncMobileQueuedChanges(client, syncQueueScope, {
onSynced: async (memo, item) => {
if (item.kind === "memo.create") {
await replaceLocalMemoId(dataScope, item.memoId, memo);
onMemoIdRemapped(item.memoId, memo);
} else {
await upsertLocalMemo(dataScope, memo);
if (retryable > 0) {
await syncMobileQueuedChanges(client, syncQueueScope, {
force: shouldForce,
onSynced: async (memo, item) => {
if (item.kind === "memo.create") {
await replaceLocalMemoId(dataScope, item.memoId, memo);
onMemoIdRemapped(item.memoId, memo);
} else {
await upsertLocalMemo(dataScope, memo);
}
queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo });
queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo });
},
});
const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope);
if (nextSummary.total === 0) {
await invalidateSyncedWorkspace();
}
queryClient.setQueryData(["mobile", "memo", "notebook", memo.id], { memo });
queryClient.setQueryData(["mobile", "memo", "trash", memo.id], { memo });
},
});
}
const nextSummary = await loadMobileSyncQueueSummary(syncQueueScope);
if (nextSummary.total === 0) {
await invalidateSyncedWorkspace();
if (!requestedRef.current) {
break;
}
shouldForce = requestedForceRef.current;
requestedRef.current = false;
requestedForceRef.current = false;
}
} catch {
// Queue metadata remains durable; the scheduled pass resumes it.
@@ -94,8 +108,10 @@ export const useMobileAutomaticSync = ({
runningRef.current = false;
if (requestedRef.current) {
const nextForce = requestedForceRef.current;
requestedRef.current = false;
void runRef.current();
requestedForceRef.current = false;
void runRef.current(nextForce);
return;
}
@@ -103,12 +119,16 @@ export const useMobileAutomaticSync = ({
if (retryDelay !== null) {
retryTimerRef.current = setTimeout(() => {
retryTimerRef.current = null;
void runRef.current();
void runRef.current(false);
}, retryDelay);
}
}
}, [client, dataScope, invalidateSyncedWorkspace, onMemoIdRemapped, queryClient, refreshSyncQueueItems, syncQueueScope]);
const runForcedSync = useCallback(async () => {
await runAutomaticSync(true);
}, [runAutomaticSync]);
runRef.current = runAutomaticSync;
useEffect(() => {
@@ -116,11 +136,12 @@ export const useMobileAutomaticSync = ({
return;
}
void runRef.current();
void runRef.current(false);
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (nextState === "active") {
void runRef.current();
// Returning to the app should immediately retry any deferred failures.
void runRef.current(true);
}
});
@@ -136,6 +157,7 @@ export const useMobileAutomaticSync = ({
return {
refreshSyncQueueItems,
runAutomaticSync,
runForcedSync,
syncQueueItems,
};
};
+6
View File
@@ -39,6 +39,12 @@ const mobileOnlyTranslations = new Map<string, string>([
["同步失败", "Sync failed"],
["待同步", "Pending sync"],
["已同步", "Synced"],
["立即同步", "Sync now"],
["查看同步状态并立即重试", "View sync status and retry now"],
["本地改动还在等待上传到云端。可立即重试同步。", "Local changes are waiting to upload. You can retry sync now."],
["本地改动未能上传到云端。可立即重试同步。", "Local changes could not upload. You can retry sync now."],
["本地改动未能上传到云端。内容仍保存在本机,可立即重试。", "Local changes could not upload. They remain on this device and you can retry now."],
["本地改动待上传。下拉刷新或点此可立即同步。", "Local changes are pending upload. Pull to refresh or tap to sync now."],
["查看并处理同步冲突", "Review and resolve the sync conflict"],
["云端笔记已在其他标签页、设备,或离线期间被更新,本地草稿无法直接覆盖。可先复制本地草稿,再采用云端版本后继续编辑。", "The cloud note was updated in another tab, on another device, or while you were offline. Copy your local draft, then use the cloud version before editing again."],
["云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。", "The cloud note was updated in another tab, on another device, or while you were offline. Copy your local draft, then use the cloud version before editing again."],
+58
View File
@@ -17,10 +17,13 @@ mock.module("@react-native-async-storage/async-storage", () => ({
}));
const {
armMobileSyncQueueImmediateRetry,
cancelMobileMemoQueueItems,
clearMobileMemoUpdateQueueItem,
discardMobileMemoConflict,
getMobileConflictDraftClipboardText,
listMobileSyncQueueItems,
markMobileMemoUpdateError,
queueMobileMemoCreate,
queueMobileMemoUpdate,
syncMobileQueuedChanges,
@@ -236,6 +239,61 @@ test("cancels queue items for a memo", async () => {
expect(remaining[0]?.memoId).toBe("memo-2");
});
test("force sync ignores backoff and retries errored items immediately", async () => {
const scope = "https://one.example";
await queueMobileMemoUpdate(scope, basePayload);
await markMobileMemoUpdateError(scope, "memo-1", "temporary outage");
// Simulate exponential backoff far in the future.
const items = await listMobileSyncQueueItems(scope);
expect(items[0]?.status).toBe("error");
let updateCalled = false;
const client = {
createMemoEditSession: async () => ({
editSession: { id: "edit-1", baseRevision: 1, baseContentHash: "hash-1" },
}),
updateMemo: async () => {
updateCalled = true;
return { memo: createMemo({ revision: 2, contentHash: "hash-2", contentMarkdown: "first" }) };
},
};
// Without force, a far-future nextAttemptAt would skip the item. Arm + force clears it.
await armMobileSyncQueueImmediateRetry(scope);
const result = await syncMobileQueuedChanges(client as never, scope, { force: true });
expect(result.synced).toBe(1);
expect(updateCalled).toBe(true);
expect(await listMobileSyncQueueItems(scope)).toHaveLength(0);
});
test("clearMobileMemoUpdateQueueItem drops a stale outbox update after online save", async () => {
const scope = "https://one.example";
await queueMobileMemoUpdate(scope, basePayload);
await clearMobileMemoUpdateQueueItem(scope, "memo-1");
expect(await listMobileSyncQueueItems(scope)).toHaveLength(0);
});
test("content_conflict is classified as a conflict instead of a retryable error", async () => {
const scope = "https://one.example";
await queueMobileMemoUpdate(scope, basePayload);
const result = await syncMobileQueuedChanges({
createMemoEditSession: async () => ({
editSession: { id: "edit-1", baseRevision: 1, baseContentHash: "hash-1" },
}),
updateMemo: async () => {
throw new ApiRequestError("Note content changed after this edit session started.", 409, "content_conflict");
},
} as never, scope);
const queued = (await listMobileSyncQueueItems(scope))[0];
expect(result.conflicted).toBe(1);
expect(result.failed).toBe(0);
expect(queued?.status).toBe("conflict");
expect(queued?.lastError).toContain("content changed");
});
test("soft-deletes the remote orphan when an offline create is cancelled mid-sync", async () => {
const scope = "https://one.example";
await queueMobileMemoCreate(scope, {
+100 -7
View File
@@ -147,6 +147,91 @@ export const deleteMobileSyncQueueItem = async (scope: string, id: string) => {
return loadMobileSyncQueueSummary(scope);
};
/** Drop a successful online update so a stale outbox entry cannot re-apply older content. */
export const clearMobileMemoUpdateQueueItem = async (scope: string, memoId: string) => {
await removeMobileSyncQueueItem(scope, getMobileMemoUpdateQueueId(memoId));
return loadMobileSyncQueueSummary(scope);
};
/**
* Clear backoff so the next sync pass retries immediately.
* Conflict items stay conflicted until the user resolves them.
*/
export const armMobileSyncQueueImmediateRetry = async (scope: string) => {
const now = new Date().toISOString();
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.status === "conflict") {
return item;
}
if (item.status === "pending" || item.status === "error" || item.status === "syncing") {
return {
...item,
status: "pending",
nextAttemptAt: null,
updatedAt: now,
};
}
return item;
})
);
return loadMobileSyncQueueSummary(scope);
};
export const markMobileMemoUpdateConflict = async (scope: string, memoId: string, lastError: string) => {
const id = getMobileMemoUpdateQueueId(memoId);
const now = new Date().toISOString();
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id && !(item.kind === "memo.create" && item.memoId === memoId)) {
return item;
}
return {
...item,
status: "conflict",
lastError,
nextAttemptAt: null,
updatedAt: now,
};
})
);
return loadMobileSyncQueueSummary(scope);
};
export const markMobileMemoUpdateError = async (scope: string, memoId: string, lastError: string) => {
const id = getMobileMemoUpdateQueueId(memoId);
const now = new Date().toISOString();
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id && !(item.kind === "memo.create" && item.memoId === memoId)) {
return item;
}
return {
...item,
status: "error",
lastError,
nextAttemptAt: null,
updatedAt: now,
};
})
);
return loadMobileSyncQueueSummary(scope);
};
export const getMobileSyncErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : "Sync failed";
export const isMobileSyncConflictError = (error: unknown) => {
if (!(error instanceof ApiRequestError)) {
return false;
}
if (error.status === 409) {
return true;
}
const code = (error.code ?? "").toLowerCase();
return code.includes("conflict");
};
/** Drop every create/update queue entry for a memo (used when discarding local-only notes). */
export const cancelMobileMemoQueueItems = async (scope: string, memoId: string) => {
await mutateMobileSyncQueue(scope, (items) => items.filter((item) => item.memoId !== memoId));
@@ -213,14 +298,19 @@ export const syncMobileQueuedChanges = async (
client: ReturnType<typeof createEdgeEverClient>,
scope: string,
options: {
force?: boolean;
onSynced?: (memo: MemoDetail, item: MobileSyncQueueItem) => void | Promise<void>;
} = {}
): Promise<MobileSyncRunResult> => {
if (options.force) {
await armMobileSyncQueueImmediateRetry(scope);
}
const result = createEmptySyncRunResult();
const now = new Date();
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)
.filter((item) => options.force || !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
for (const item of items) {
@@ -256,13 +346,13 @@ export const syncMobileQueuedChanges = async (
}
result.synced += 1;
} catch (error) {
const status = isRevisionConflict(error) ? "conflict" : "error";
const status = isMobileSyncConflictError(error) ? "conflict" : "error";
const attemptCount = item.attemptCount + 1;
await updateMobileSyncQueueItem(scope, item.id, {
status,
attemptCount,
lastError: getErrorMessage(error),
lastError: getMobileSyncErrorMessage(error),
nextAttemptAt: status === "error" ? getSyncRetryAt(attemptCount) : null,
updatedAt: new Date().toISOString(),
}, itemVersion);
@@ -280,10 +370,15 @@ export const syncMobileQueuedChanges = async (
export const shouldQueueMobileMemoSaveError = (error: unknown) => {
if (error instanceof ApiRequestError) {
return error.status === 408 || error.status === 429 || error.status >= 500;
return error.status === 0 || error.status === 408 || error.status === 429 || error.status >= 500;
}
return error instanceof TypeError || getErrorMessage(error).toLowerCase().includes("network");
const message = getMobileSyncErrorMessage(error).toLowerCase();
return error instanceof TypeError
|| message.includes("network")
|| message.includes("failed to fetch")
|| message.includes("network request failed")
|| message.includes("timeout");
};
const syncMobileQueueItem = async (client: ReturnType<typeof createEdgeEverClient>, item: MobileSyncQueueItem) => {
@@ -466,6 +561,4 @@ const isMobileSyncQueueItem = (value: unknown): value is MobileSyncQueueItem =>
return (item.kind === "memo.update" || item.kind === "memo.create") && typeof item.id === "string" && typeof item.memoId === "string" && Boolean(item.payload);
};
const isRevisionConflict = (error: unknown) => error instanceof ApiRequestError && error.code === "revision_conflict";
const getErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Sync failed");
@@ -304,8 +304,10 @@ export const MemoDetailModal = ({
onOpenRevisions,
onRenameResource,
onResolveSyncConflict,
onRetrySync,
onRestore,
onShare,
syncError,
syncStatus,
visible,
}: {
@@ -325,8 +327,10 @@ export const MemoDetailModal = ({
onOpenRevisions: (memo: MemoDetail) => void;
onRenameResource: (memo: MemoDetail, target: MobileResourceTarget, filename: string) => Promise<void>;
onResolveSyncConflict: (memo: MemoDetail) => void;
onRetrySync: () => void;
onRestore: (memo: MemoDetail) => void;
onShare: (memo: MemoDetail) => void;
syncError: string | null;
syncStatus: MobileSyncQueueItem["status"] | null;
visible: boolean;
}) => {
@@ -421,6 +425,26 @@ export const MemoDetailModal = ({
: syncStatus === "pending"
? "待同步"
: "已同步";
const syncStatusInteractive = syncStatus === "conflict" || syncStatus === "error" || syncStatus === "pending";
const handleSyncStatusPress = () => {
if (!memo) {
return;
}
if (syncStatus === "conflict") {
onResolveSyncConflict(memo);
return;
}
if (syncStatus === "error" || syncStatus === "pending") {
const detail = syncError?.trim()
|| (syncStatus === "pending"
? "本地改动还在等待上传到云端。可立即重试同步。"
: "本地改动未能上传到云端。可立即重试同步。");
Alert.alert(syncStatusLabel, detail, [
{ text: "取消", style: "cancel" },
{ text: "立即同步", onPress: onRetrySync },
]);
}
};
const editFabBottom = Math.max(
safeAreaInsets.bottom,
Platform.OS === "android" ? ANDROID_SYSTEM_NAVIGATION_FALLBACK : 0
@@ -465,15 +489,26 @@ export const MemoDetailModal = ({
</Pressable>
<View style={styles.detailHeaderActions}>
<Pressable
accessibilityHint={syncStatus === "conflict" ? "查看并处理同步冲突" : undefined}
accessibilityHint={
syncStatus === "conflict"
? "查看并处理同步冲突"
: syncStatusInteractive
? "查看同步状态并立即重试"
: undefined
}
accessibilityLabel={syncStatusLabel}
accessibilityRole={syncStatus === "conflict" ? "button" : "text"}
disabled={syncStatus !== "conflict" || !memo}
onPress={() => memo && onResolveSyncConflict(memo)}
accessibilityRole={syncStatusInteractive ? "button" : "text"}
disabled={!syncStatusInteractive || !memo}
onPress={handleSyncStatusPress}
>
<Text
numberOfLines={1}
style={[styles.detailSyncStatus, syncStatus === "conflict" && styles.detailSyncStatusConflict]}
style={[
styles.detailSyncStatus,
syncStatus === "conflict" && styles.detailSyncStatusConflict,
syncStatus === "error" && styles.detailSyncStatusError,
syncStatus === "pending" && styles.detailSyncStatusPending,
]}
>
{syncStatusLabel}
</Text>
@@ -522,6 +557,7 @@ export const MemoDetailModal = ({
<Text style={styles.conflictBannerText}>
云端笔记已在其他标签页、设备,或离线期间被更新。可先复制本地草稿,再采用云端版本后继续编辑。
</Text>
{syncError ? <Text style={styles.conflictBannerText}>{syncError}</Text> : null}
<View style={styles.conflictBannerActions}>
<Pressable
accessibilityLabel="采用云端并重新加载"
@@ -551,6 +587,36 @@ export const MemoDetailModal = ({
</View>
) : null}
{(syncStatus === "error" || syncStatus === "pending") && memo ? (
<View style={syncStatus === "error" ? styles.syncErrorBanner : styles.syncPendingBanner}>
<Text style={syncStatus === "error" ? styles.syncErrorBannerText : styles.syncPendingBannerText}>
{syncStatus === "error"
? (syncError?.trim() || "本地改动未能上传到云端。内容仍保存在本机,可立即重试。")
: (syncError?.trim() || "本地改动待上传。下拉刷新或点此可立即同步。")}
</Text>
<View style={styles.conflictBannerActions}>
<Pressable
accessibilityLabel="立即同步"
accessibilityRole="button"
onPress={onRetrySync}
style={syncStatus === "error" ? styles.syncErrorBannerPrimaryButton : styles.syncPendingBannerPrimaryButton}
>
<Text style={styles.conflictBannerPrimaryButtonText}>立即同步</Text>
</Pressable>
{syncStatus === "error" ? (
<Pressable
accessibilityLabel="复制本地草稿"
accessibilityRole="button"
onPress={() => onCopyLocalDraft(memo)}
style={styles.syncErrorBannerSecondaryButton}
>
<Text style={styles.syncErrorBannerSecondaryButtonText}>复制本地草稿</Text>
</Pressable>
) : null}
</View>
</View>
) : null}
{isLoading ? (
<View style={styles.centerState}>
<ActivityIndicator color="#0f172a" />
+106 -22
View File
@@ -68,6 +68,7 @@ import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context"
import { Alert, Pressable, Text, TextInput } from "../components/LocalizedText";
import Markdown, { type ASTNode, type RenderRules } from "react-native-markdown-display";
import { SvgXml } from "react-native-svg";
import { ApiRequestError } from "@edgeever/client";
import { buildGitHubFeedbackUrl, createExcerpt, docToMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, resolveMemoContentDoc, type AuthUser, type MemoDetail, type MemoRevision, type MemoSummary, type Notebook, type TiptapDoc } from "@edgeever/shared";
import { MOBILE_UI_METRICS, getMobileCenteredScrollOffset, getMobileNotebookSearchVisibleIds, toggleMobileMemoFilterMode, toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui";
import { clearMobileMemoDraft, clearMobileNewMemoDraft, readMobileMemoDraft, readMobileNewMemoDraft, writeMobileMemoDraft, writeMobileNewMemoDraft, type MobileMemoDraft } from "../lib/mobile-drafts";
@@ -82,12 +83,18 @@ import {
import { useMobileLocale } from "../lib/mobile-locale";
import { useSession } from "../lib/session";
import {
clearMobileMemoUpdateQueueItem,
deleteMobileSyncQueueItem,
discardMobileMemoConflict,
getMobileConflictDraftClipboardText,
getMobileSyncErrorMessage,
isMobileSyncConflictError,
listMobileSyncQueueItems,
markMobileMemoUpdateConflict,
markMobileMemoUpdateError,
queueMobileMemoCreate,
queueMobileMemoUpdate,
shouldQueueMobileMemoSaveError,
type MobileSyncQueueItem,
} from "../lib/sync-queue";
import { deleteMobileMemos } from "../lib/mobile-memo-delete";
@@ -237,7 +244,7 @@ export const WorkspaceScreen = ({
}, []);
const {
refreshSyncQueueItems,
runAutomaticSync,
runForcedSync,
syncQueueItems,
} = useMobileAutomaticSync({
client,
@@ -401,7 +408,10 @@ export const WorkspaceScreen = ({
}, [memosQuery.data, notebooksQuery.data]);
const refresh = async () => {
// Pull-to-refresh must push the outbox first; previously it only pulled
// the server mirror, so pending local edits stayed stuck on "待同步".
if (client) {
await runForcedSync();
await syncMobileLocalMirror(client, dataScope);
}
await Promise.all([
@@ -409,6 +419,7 @@ export const WorkspaceScreen = ({
queryClient.invalidateQueries({ queryKey: ["mobile", "memos"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "search"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "memo"] }),
refreshSyncQueueItems(),
]);
};
@@ -540,9 +551,11 @@ export const WorkspaceScreen = ({
const searchActive = searchText.trim().length > 0;
const visibleMemos = searchActive ? searchResults : memos;
const selectedMemo = memoDetailQuery.data?.memo ?? null;
const selectedMemoSyncStatus = selectedMemo
? syncQueueItems.find((item) => item.memoId === selectedMemo.id)?.status ?? null
const selectedMemoSyncItem = selectedMemo
? syncQueueItems.find((item) => item.memoId === selectedMemo.id) ?? null
: null;
const selectedMemoSyncStatus = selectedMemoSyncItem?.status ?? null;
const selectedMemoSyncError = selectedMemoSyncItem?.lastError ?? null;
const isRefreshing = notebooksQuery.isFetching || memosQuery.isFetching || searchQuery.isFetching || memoDetailQuery.isFetching;
const selectedMemoIdList = Array.from(selectedMemoIds);
const selectedMemos = visibleMemos.filter((memo) => selectedMemoIds.has(memo.id));
@@ -781,8 +794,7 @@ export const WorkspaceScreen = ({
mutationFn: async ({ memo, payload }: { memo: MemoDetail; payload: MobileMemoUpdatePayload }) => {
const syncBaseMemo = await resolveLocalMemo(dataScope, memo.id) ?? memo;
const optimisticMemo = createOptimisticMemo(syncBaseMemo, payload);
await queueMobileMemoUpdate(syncQueueScope, {
const queuePayload = {
memoId: syncBaseMemo.id,
expectedRevision: syncBaseMemo.revision,
expectedContentHash: syncBaseMemo.contentHash,
@@ -790,15 +802,69 @@ export const WorkspaceScreen = ({
contentMarkdown: optimisticMemo.contentMarkdown,
notebookId: optimisticMemo.notebookId,
tags: optimisticMemo.tags,
});
await refreshSyncQueueItems();
};
// Online-first: push immediately when the instance is reachable so common
// edits never sit in "待同步". Fall back to the durable outbox only when
// the network or server cannot accept the write right now.
if (client && !syncBaseMemo.id.startsWith("local:")) {
try {
const editSessionResponse = payload.contentMarkdown !== undefined
? await client.createMemoEditSession(syncBaseMemo.id)
: null;
if (
editSessionResponse
&& (
editSessionResponse.editSession.baseRevision !== syncBaseMemo.revision
|| editSessionResponse.editSession.baseContentHash !== syncBaseMemo.contentHash
)
) {
throw new ApiRequestError("Note changed before the offline draft could sync.", 409, "revision_conflict");
}
const response = await client.updateMemo(syncBaseMemo.id, {
expectedRevision: syncBaseMemo.revision,
...(editSessionResponse
? {
expectedContentHash: syncBaseMemo.contentHash,
editSessionId: editSessionResponse.editSession.id,
}
: {}),
...payload,
});
await clearMobileMemoUpdateQueueItem(syncQueueScope, syncBaseMemo.id);
await refreshSyncQueueItems();
return response.memo;
} catch (error) {
const message = getMobileSyncErrorMessage(error);
if (isMobileSyncConflictError(error)) {
await queueMobileMemoUpdate(syncQueueScope, queuePayload);
await markMobileMemoUpdateConflict(syncQueueScope, syncBaseMemo.id, message);
await refreshSyncQueueItems();
return optimisticMemo;
}
await queueMobileMemoUpdate(syncQueueScope, queuePayload);
if (!shouldQueueMobileMemoSaveError(error)) {
await markMobileMemoUpdateError(syncQueueScope, syncBaseMemo.id, message);
}
await refreshSyncQueueItems();
// Durable local save succeeded; outbox will retry. Do not fail the editor.
return optimisticMemo;
}
}
await queueMobileMemoUpdate(syncQueueScope, queuePayload);
await refreshSyncQueueItems();
return optimisticMemo;
},
onSuccess: async (memo, variables) => {
await upsertLocalMemo(dataScope, memo);
applyOptimisticMemoToCache(queryClient, variables.memo, memo);
void runAutomaticSync();
void runForcedSync();
},
});
@@ -1126,8 +1192,12 @@ export const WorkspaceScreen = ({
onAdoptCloudVersion={(memo) => void handleAdoptCloudVersion(memo)}
onCopyLocalDraft={(memo) => void handleCopyConflictDraft(memo)}
onResolveSyncConflict={handleMemoSyncConflict}
onRetrySync={() => {
void runForcedSync();
}}
onRestore={(memo) => restoreMemoMutation.mutate(memo)}
onShare={(memo) => shareMemoMutation.mutate(memo)}
syncError={selectedMemoSyncError}
syncStatus={selectedMemoSyncStatus}
visible={Boolean(selectedMemoId)}
/>
@@ -1176,7 +1246,7 @@ export const WorkspaceScreen = ({
setMemoView("notebook");
setSelectedMemoId(null);
}}
onQueued={runAutomaticSync}
onQueued={runForcedSync}
syncQueueScope={syncQueueScope}
visible={createOpen}
/>
@@ -2281,6 +2351,7 @@ const RichEditorModal = ({
const flushResolverRef = useRef<(() => void) | null>(null);
const savingRef = useRef(false);
const uploadingRef = useRef(false);
const memoBaseRef = useRef(memo);
const [title, setTitle] = useState(resolveEditableMemoTitle(restoredDraft?.title ?? memo?.title));
const [tagsText, setTagsText] = useState(restoredDraft?.tagsText ?? memo?.tags.join(", ") ?? "");
const [notebookId, setNotebookId] = useState(restoredDraft?.notebookId ?? memo?.notebookId ?? "");
@@ -2310,7 +2381,8 @@ const RichEditorModal = ({
}, []);
const persistDraft = async (contentJson: TiptapDoc) => {
if (!memo) {
const currentMemo = memoBaseRef.current;
if (!currentMemo) {
return;
}
const contentSnapshot = JSON.stringify(contentJson);
@@ -2328,8 +2400,8 @@ const RichEditorModal = ({
flushResolverRef.current?.();
flushResolverRef.current = null;
await writeMobileMemoDraft({
memoId: memo.id,
expectedRevision: memo.revision,
memoId: currentMemo.id,
expectedRevision: currentMemo.revision,
title: titleRef.current.trim(),
contentMarkdown: contentMarkdownRef.current,
notebookId: notebookIdRef.current,
@@ -2339,19 +2411,23 @@ const RichEditorModal = ({
};
const save = async () => {
if (!memo || savingRef.current || !notebookIdRef.current) {
const currentMemo = memoBaseRef.current;
if (!currentMemo || savingRef.current || !notebookIdRef.current) {
return null;
}
if (!dirtyRef.current) {
return memo;
return currentMemo;
}
// Capture whether the user kept typing while this save is in flight so we
// do not clear the dirty flag and drop their next autosave.
const dirtyGenerationAtStart = contentSnapshotRef.current;
savingRef.current = true;
setSaving(true);
setError(null);
try {
const savedMemo = await updateMutation.mutateAsync({
memo,
memo: currentMemo,
payload: {
title: titleRef.current.trim() || DEFAULT_MEMO_TITLE,
contentJson: contentJsonRef.current,
@@ -2360,9 +2436,16 @@ const RichEditorModal = ({
tags: parseTags(tagsTextRef.current),
},
});
await clearMobileMemoDraft(memo.id);
dirtyRef.current = false;
setDirty(false);
memoBaseRef.current = savedMemo;
await clearMobileMemoDraft(currentMemo.id);
if (contentSnapshotRef.current === dirtyGenerationAtStart) {
dirtyRef.current = false;
setDirty(false);
} else {
// Newer local edits arrived during the save; keep dirty so the next pass uploads them.
dirtyRef.current = true;
setDirty(true);
}
setDraftRestored(false);
return savedMemo;
} catch (saveError) {
@@ -2556,13 +2639,14 @@ const RichEditorModal = ({
);
useEffect(() => {
if (!memo || !dirty) {
const currentMemo = memoBaseRef.current;
if (!currentMemo || !dirty) {
return;
}
const timeout = setTimeout(() => {
void writeMobileMemoDraft({
memoId: memo.id,
expectedRevision: memo.revision,
memoId: currentMemo.id,
expectedRevision: currentMemo.revision,
title: titleRef.current.trim(),
contentMarkdown: contentMarkdownRef.current,
notebookId: notebookIdRef.current,
@@ -2574,7 +2658,7 @@ const RichEditorModal = ({
}, [dirty, memo, notebookId, tagsText, title]);
useEffect(() => {
if (!memo || !dirty || !ready || savingRef.current || uploadingRef.current) {
if (!memoBaseRef.current || !dirty || !ready || savingRef.current || uploadingRef.current) {
return;
}
const timeout = setTimeout(() => {
@@ -1499,6 +1499,67 @@ const baseWorkspaceStyles = StyleSheet.create({
color: "#c2410c",
fontWeight: "700",
},
detailSyncStatusError: {
backgroundColor: "#fef2f2",
color: "#b91c1c",
fontWeight: "700",
},
detailSyncStatusPending: {
backgroundColor: "#eff6ff",
color: "#1d4ed8",
fontWeight: "600",
},
syncErrorBanner: {
backgroundColor: "#fef2f2",
borderBottomColor: "#fecaca",
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
},
syncErrorBannerText: {
color: "#991b1b",
fontSize: 12,
lineHeight: 18,
},
syncErrorBannerPrimaryButton: {
backgroundColor: "#b91c1c",
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
},
syncErrorBannerSecondaryButton: {
backgroundColor: "#fff",
borderColor: "#fecaca",
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 10,
paddingVertical: 8,
},
syncErrorBannerSecondaryButtonText: {
color: "#991b1b",
fontSize: 12,
fontWeight: "600",
},
syncPendingBanner: {
backgroundColor: "#eff6ff",
borderBottomColor: "#bfdbfe",
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 10,
paddingHorizontal: 14,
paddingVertical: 12,
},
syncPendingBannerText: {
color: "#1e3a8a",
fontSize: 12,
lineHeight: 18,
},
syncPendingBannerPrimaryButton: {
backgroundColor: "#1d4ed8",
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
},
conflictBanner: {
backgroundColor: "#fff1f2",
borderBottomColor: "#fecdd3",