fix(web): prevent autosave sync races (#210)

This commit is contained in:
tianma-if committed 2026-08-10 11:34:12 +08:00
1 parent b7043e7aaf
commit 3c8a51f6a3
7 files changed
+381 -32

No files matched your search

+34 -20
View File
@@ -143,7 +143,7 @@ import {
isAttachmentLinkHref,
} from "@/lib/editor-external-link";
import { processFilesSequentially } from "@/lib/file-batch";
import { MEMO_ID_REMAPPED_EVENT } from "@/lib/sync-events";
import { MEMO_ID_REMAPPED_EVENT, MEMO_SYNC_ACKNOWLEDGED_EVENT } from "@/lib/sync-events";
import { useStandaloneMobileEditor } from "@/hooks/useStandaloneMobileEditor";
import { statusSettleMotion } from "@/lib/motion";
import { getAttachmentFilenameFromLabel, getAttachmentResourceId } from "@/lib/attachment-links";
@@ -2117,6 +2117,33 @@ const RichEditorPane = ({
}, [editor, markDirty, memo, persistCurrentDraft]);
useEffect(() => {
const advanceMemoSyncBase = (syncedMemo: MemoDetail | null | undefined) => {
const currentMemo = memoRef.current;
if (!syncedMemo || currentMemo?.id !== syncedMemo.id || syncedMemo.revision < currentMemo.revision) {
return;
}
// Keep the live document, title, and tags intact while moving its
// concurrency base to the revision acknowledged for this device.
memoRef.current = {
...currentMemo,
revision: syncedMemo.revision,
contentHash: syncedMemo.contentHash,
updatedAt: syncedMemo.updatedAt,
};
if (editSessionRef.current?.memoId === syncedMemo.id) {
editSessionRef.current = {
...editSessionRef.current,
baseRevision: syncedMemo.revision,
baseContentHash: syncedMemo.contentHash,
};
}
};
const handleMemoSyncAcknowledged = (event: Event) => {
advanceMemoSyncBase((event as CustomEvent<MemoDetail>).detail);
};
const handleSyncCompleted = (event: Event) => {
const result = (event as CustomEvent<{
failed?: number;
@@ -2126,24 +2153,7 @@ const RichEditorPane = ({
const memoId = memoRef.current?.id;
const syncedMemo = memoId ? result?.syncedMemos?.get(memoId) : null;
if (syncedMemo && memoRef.current?.id === syncedMemo.id) {
// Keep the live editor document, title, and tags intact while moving
// its concurrency base to the revision just acknowledged by this
// device. This closes the query-refresh window after an autosave.
memoRef.current = {
...memoRef.current,
revision: syncedMemo.revision,
contentHash: syncedMemo.contentHash,
updatedAt: syncedMemo.updatedAt,
};
if (editSessionRef.current?.memoId === syncedMemo.id) {
editSessionRef.current = {
...editSessionRef.current,
baseRevision: syncedMemo.revision,
baseContentHash: syncedMemo.contentHash,
};
}
}
advanceMemoSyncBase(syncedMemo);
if (memoId && (result?.conflicted ?? 0) > 0) {
void localDb.syncQueue.get(getMemoUpdateQueueId(memoId)).then((item) => {
@@ -2169,8 +2179,12 @@ const RichEditorPane = ({
});
};
window.addEventListener(MEMO_SYNC_ACKNOWLEDGED_EVENT, handleMemoSyncAcknowledged);
window.addEventListener("edgeever:sync-completed", handleSyncCompleted);
return () => window.removeEventListener("edgeever:sync-completed", handleSyncCompleted);
return () => {
window.removeEventListener(MEMO_SYNC_ACKNOWLEDGED_EVENT, handleMemoSyncAcknowledged);
window.removeEventListener("edgeever:sync-completed", handleSyncCompleted);
};
}, []);
const handleMarkdownModeChange = useCallback(() => {
+7 -1
View File
@@ -3,7 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
import type { MemoDetail, MemoTemplate, Notebook, Resource } from "@edgeever/shared";
import { isBrowserOffline } from "@/lib/network-status";
import { emptySyncQueueSummary, type SyncQueueSummary } from "@/lib/sync-queue";
import { notifyMemoIdRemapped } from "@/lib/sync-events";
import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged } from "@/lib/sync-events";
import { putLocalMemo, replaceLocalMemoId } from "@/lib/local-mirror";
import { resolveCreatedMemoSelection, resolveSyncedMemoId } from "@/lib/workspace-refresh";
@@ -64,6 +64,12 @@ export const useWorkspaceQueuedSync = ({
const { syncQueuedChanges } = await import("@/lib/sync-queue");
const result = await syncQueuedChanges({
scope: localDataScope,
onMemoAcknowledged: async (memo) => {
// Advance the live editor's concurrency base before the acknowledged
// server snapshot reaches React Query. This keeps a self-sync from
// being mistaken for new document content and moving the caret.
notifyMemoSyncAcknowledged(memo);
},
onSynced: async (memo, item) => {
if (item.kind === "memo.create") {
const remappedMemo = await replaceLocalMemoId(localDataScope, item.memoId, memo);
+7
View File
@@ -1,6 +1,9 @@
import type { MemoDetail } from "@edgeever/shared";
export const SYNC_QUEUE_CHANGED_EVENT = "edgeever:sync-queue-changed";
export const SYNC_QUEUE_DEFERRED_EVENT = "edgeever:sync-queue-deferred";
export const MEMO_ID_REMAPPED_EVENT = "edgeever:memo-id-remapped";
export const MEMO_SYNC_ACKNOWLEDGED_EVENT = "edgeever:memo-sync-acknowledged";
export const notifySyncQueueChanged = () => {
window.dispatchEvent(new CustomEvent(SYNC_QUEUE_CHANGED_EVENT));
@@ -14,3 +17,7 @@ export const notifyMemoIdRemapped = (memoIdMappings: ReadonlyMap<string, string>
if (memoIdMappings.size === 0) return;
window.dispatchEvent(new CustomEvent(MEMO_ID_REMAPPED_EVENT, { detail: memoIdMappings }));
};
export const notifyMemoSyncAcknowledged = (memo: MemoDetail) => {
window.dispatchEvent(new CustomEvent(MEMO_SYNC_ACKNOWLEDGED_EVENT, { detail: memo }));
};
+69
View File
@@ -30,6 +30,75 @@ afterEach(async () => {
});
describe("web sync conflict recovery", () => {
test("preserves a newer draft written while a memo update is in flight", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
const createdMemo = await createLocalMemo(scope, { notebookId: "inbox" });
const remoteMemo = {
...createdMemo,
id: "memo-draft-in-flight",
revision: 1,
contentHash: "hash-1",
};
const syncedContent = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "synced snapshot" }] }] };
const newerContent = { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "still typing" }] }] };
await putLocalMemo(scope, remoteMemo);
await queueMemoUpdate({
memoId: remoteMemo.id,
expectedRevision: 1,
expectedContentHash: "hash-1",
editSessionId: "edit-a",
title: "Synced snapshot",
contentJson: syncedContent,
tags: [],
}, scope);
let updateStarted;
let releaseUpdate;
const updateStartedPromise = new Promise((resolve) => { updateStarted = resolve; });
const releaseUpdatePromise = new Promise((resolve) => { releaseUpdate = resolve; });
const original = {
createMemoEditSession: api.createMemoEditSession,
updateMemo: api.updateMemo,
};
api.createMemoEditSession = async () => ({ editSession: { id: "fresh-edit", baseRevision: 1, baseContentHash: "hash-1" } });
api.updateMemo = async (_memoId, payload) => {
updateStarted();
await releaseUpdatePromise;
return { memo: { ...remoteMemo, ...payload, revision: 2, contentHash: "hash-2" } };
};
try {
const callbackOrder = [];
const syncing = syncQueuedChanges({
scope,
onMemoAcknowledged: () => callbackOrder.push("acknowledged"),
onSynced: () => callbackOrder.push("synced"),
});
await updateStartedPromise;
await localDb.drafts.put({
memoId: remoteMemo.id,
title: "Still typing",
tagsText: "",
contentJson: newerContent,
updatedAt: "2099-01-01T00:00:00.000Z",
});
releaseUpdate();
expect(await syncing).toMatchObject({ synced: 1, conflicted: 0 });
expect(callbackOrder).toEqual(["acknowledged", "synced"]);
expect(await localDb.drafts.get(remoteMemo.id)).toMatchObject({
title: "Still typing",
contentJson: newerContent,
});
} finally {
api.createMemoEditSession = original.createMemoEditSession;
api.updateMemo = original.updateMemo;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
}
});
test("preserves a draft written while a new memo create request is in flight", async () => {
const previousOnline = globalThis.navigator?.onLine;
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
+96 -10
View File
@@ -23,15 +23,18 @@ import {
type MemoRestoreSyncPayload,
type LocalActionKind,
type LocalActionPayload,
type LocalDraft,
type MemoUpdateSyncPayload,
type SyncQueueItem,
} from "@/lib/local-db";
import { getMemoSaveConflictInfo, parseMemoSaveConflictDetails } from "@/lib/memo-save-conflict";
import { getCachedLocalResourceBytes, removeCachedLocalResourceBytes } from "@/lib/local-resource-cache";
import { isBrowserOffline } from "@/lib/network-status";
import { parseTagsText } from "@/lib/utils";
export type { SyncQueueSummary, SyncRunResult } from "@edgeever/shared";
export type SyncQueueResult = MemoDetail | Notebook | MemoTemplate | Resource | null;
export type MemoUpdateAcknowledgement = "completed" | "rebased" | "stale";
export const emptySyncQueueSummary = createEmptySyncQueueSummary;
@@ -68,6 +71,19 @@ export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload, scope?: st
const now = new Date().toISOString();
await localDb.transaction("rw", localDb.syncQueue, async () => {
const existing = await localDb.syncQueue.get(id);
const existingPayload = existing?.kind === "memo.update"
? existing.payload as MemoUpdateSyncPayload
: null;
// A previous in-flight save may already have advanced this queue row to a
// newer acknowledged server base. A local autosave that started just
// before that acknowledgement must not move the successor back again.
const nextPayload = existingPayload && existingPayload.expectedRevision > payload.expectedRevision
? {
...payload,
expectedRevision: existingPayload.expectedRevision,
expectedContentHash: existingPayload.expectedContentHash,
}
: payload;
await localDb.syncQueue.put({
id,
@@ -75,7 +91,7 @@ export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload, scope?: st
scope: scope ?? existing?.scope,
memoId: payload.memoId,
status: "pending",
payload,
payload: nextPayload,
attemptCount: existing?.attemptCount ?? 0,
lastError: null,
lastErrorCode: null,
@@ -273,6 +289,7 @@ let activeSyncPromise: Promise<SyncRunResult> | null = null;
export const syncQueuedChanges = (options: {
scope?: string;
onMemoAcknowledged?: (memo: MemoDetail, item: SyncQueueItem, acknowledgement: MemoUpdateAcknowledgement) => void | Promise<void>;
onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise<void>;
onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise<void>;
} = {}): Promise<SyncRunResult> => {
@@ -289,6 +306,7 @@ export const syncQueuedChanges = (options: {
const runQueuedChanges = async (options: {
scope?: string;
onMemoAcknowledged?: (memo: MemoDetail, item: SyncQueueItem, acknowledgement: MemoUpdateAcknowledgement) => void | Promise<void>;
onSynced?: (memo: MemoDetail, item: SyncQueueItem) => void | Promise<void>;
onActionSynced?: (result: SyncQueueResult, item: SyncQueueItem) => void | Promise<void>;
}): Promise<SyncRunResult> => {
@@ -334,15 +352,19 @@ const runQueuedChanges = async (options: {
if (item.kind === "memo.create" && memo && item.scope) {
await remapQueuedMemoId(item.scope, item.memoId, memo as MemoDetail);
}
const removed = await removeClaimedQueueItem(item);
if (removed) {
if (item.kind !== "memo.create") await localDb.drafts.delete(item.memoId);
const acknowledgement = await acknowledgeClaimedQueueItem(item, memo);
if (memo && item.kind === "memo.update" && acknowledgement !== "stale") {
await options.onMemoAcknowledged?.(memo as MemoDetail, item, acknowledgement);
}
if (acknowledgement === "completed") {
if (memo && (item.kind === "memo.create" || item.kind === "memo.update" || item.kind === "memo.restore" || item.kind === "memo.delete")) {
await options.onSynced?.(memo as MemoDetail, item);
}
if (item.kind !== "memo.create" && item.kind !== "memo.update" && item.kind !== "memo.restore" && item.kind !== "memo.delete") {
await options.onActionSynced?.(memo, item);
}
}
if (acknowledgement !== "stale") {
result.synced += 1;
}
} catch (error) {
@@ -395,15 +417,79 @@ const claimQueueItem = (id: string) =>
return claimedItem;
});
const removeClaimedQueueItem = (item: SyncQueueItem) =>
localDb.transaction("rw", localDb.syncQueue, async () => {
const isDraftCoveredByMemoUpdate = (item: SyncQueueItem, draft: LocalDraft | undefined) => {
if (item.kind !== "memo.update" || !draft) return false;
const payload = item.payload as MemoUpdateSyncPayload;
return draft.title === payload.title &&
JSON.stringify(parseTagsText(draft.tagsText)) === JSON.stringify(payload.tags) &&
JSON.stringify(draft.contentJson) === JSON.stringify(payload.contentJson);
};
const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResult): Promise<MemoUpdateAcknowledgement> =>
localDb.transaction("rw", [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
const current = await localDb.syncQueue.get(item.id);
if (!current || current.claimId !== item.claimId || current.status !== "syncing") {
return false;
const memo = result && item.kind === "memo.update" && "contentHash" in result
? result as MemoDetail
: null;
if (current?.claimId === item.claimId && current.status === "syncing") {
await localDb.syncQueue.delete(item.id);
if (item.kind !== "memo.create") {
const draft = await localDb.drafts.get(item.memoId);
if (!draft || item.kind !== "memo.update" || isDraftCoveredByMemoUpdate(item, draft)) {
await localDb.drafts.delete(item.memoId);
}
}
if (memo && item.scope) {
const stored = await localDb.memos.get([item.scope, item.memoId]);
if (stored && memo.revision > stored.revision) {
await localDb.memos.put({ ...stored, revision: memo.revision, contentHash: memo.contentHash });
}
}
return "completed";
}
await localDb.syncQueue.delete(item.id);
return true;
if (
!memo ||
!current ||
current.kind !== "memo.update" ||
current.memoId !== item.memoId ||
current.claimId ||
(current.status !== "pending" && current.status !== "error")
) {
return "stale";
}
const claimedPayload = item.payload as MemoUpdateSyncPayload;
const successorPayload = current.payload as MemoUpdateSyncPayload;
const successorUsesClaimedBase = successorPayload.expectedRevision === claimedPayload.expectedRevision &&
successorPayload.expectedContentHash === claimedPayload.expectedContentHash;
const successorAlreadyRebased = successorPayload.expectedRevision === memo.revision &&
successorPayload.expectedContentHash === memo.contentHash;
if (!successorUsesClaimedBase && !successorAlreadyRebased) {
return "stale";
}
if (successorUsesClaimedBase) {
await localDb.syncQueue.put({
...current,
payload: {
...successorPayload,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
},
updatedAt: new Date().toISOString(),
});
}
if (item.scope) {
const stored = await localDb.memos.get([item.scope, item.memoId]);
if (stored && memo.revision > stored.revision) {
await localDb.memos.put({ ...stored, revision: memo.revision, contentHash: memo.contentHash });
}
}
return "rebased";
});
const updateClaimedQueueItem = (item: SyncQueueItem, patch: Partial<SyncQueueItem>) =>
+165
View File
@@ -0,0 +1,165 @@
import { expect, test, type Page, type Route } from "@playwright/test";
type StoredQueueItem = {
kind?: string;
memoId?: string;
status?: string;
payload?: unknown;
};
const readSyncQueue = (page: Page) => page.evaluate(
() => new Promise<StoredQueueItem[]>((resolve, reject) => {
const openRequest = indexedDB.open("edgeever-local");
openRequest.onerror = () => reject(openRequest.error);
openRequest.onsuccess = () => {
const database = openRequest.result;
if (!database.objectStoreNames.contains("syncQueue")) {
database.close();
resolve([]);
return;
}
const transaction = database.transaction("syncQueue", "readonly");
const request = transaction.objectStore("syncQueue").getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => {
database.close();
resolve(request.result as StoredQueueItem[]);
};
};
}),
);
const queueItemWithContent = async (page: Page, memoId: string, marker: string) => {
const item = (await readSyncQueue(page)).find((candidate) => candidate.kind === "memo.update" && candidate.memoId === memoId);
return item && JSON.stringify(item.payload).includes(marker) ? item : null;
};
const waitForQueuedContent = (page: Page, memoId: string, marker: string) => expect.poll(
async () => Boolean(await queueItemWithContent(page, memoId, marker)),
).toBe(true);
const waitForPendingQueuedContent = (page: Page, memoId: string, marker: string) => expect.poll(
async () => (await queueItemWithContent(page, memoId, marker))?.status ?? null,
).toBe("pending");
const waitForSyncCompleted = (page: Page) => page.evaluate(() => new Promise<void>((resolve) => {
window.addEventListener("edgeever:sync-completed", () => resolve(), { once: true });
}));
const readEditorSelection = (page: Page) => page.evaluate(() => {
const selection = window.getSelection();
const paragraph = selection?.anchorNode?.parentElement?.closest("p");
const range = document.createRange();
if (paragraph && selection?.anchorNode) {
range.selectNodeContents(paragraph);
range.setEnd(selection.anchorNode, selection.anchorOffset);
}
return {
offset: paragraph && selection?.anchorNode ? range.toString().length : -1,
paragraphText: paragraph?.textContent ?? "",
};
});
test("keeps the caret and rebases edits made while autosave sync is in flight", async ({ page }) => {
const marker = `sync-race-${Date.now()}`;
const notebooksResponse = await page.request.get("/api/v1/notebooks");
expect(notebooksResponse.ok()).toBe(true);
const notebooks = await notebooksResponse.json() as { notebooks: Array<{ id: string }> };
const notebookId = notebooks.notebooks[0]?.id;
expect(notebookId).toBeTruthy();
const createResponse = await page.request.post("/api/v1/memos", {
data: {
notebookId,
title: marker,
contentMarkdown: `First paragraph ${marker}\n\nSecond paragraph\n\nLast paragraph`,
},
});
expect(createResponse.status()).toBe(201);
const memoId = (await createResponse.json() as { memo: { id: string } }).memo.id;
let releaseFirstUpdate!: () => void;
let markFirstUpdateStarted!: () => void;
const firstUpdateGate = new Promise<void>((resolve) => { releaseFirstUpdate = resolve; });
const firstUpdateStarted = new Promise<void>((resolve) => { markFirstUpdateStarted = resolve; });
let updateCount = 0;
await page.route(`**/api/v1/memos/${memoId}`, async (route: Route) => {
if (route.request().method() !== "PATCH") {
await route.continue();
return;
}
updateCount += 1;
if (updateCount === 1) {
markFirstUpdateStarted();
await firstUpdateGate;
}
await route.continue();
});
try {
await page.goto("/");
await page.getByRole("button", { name: "全部笔记", exact: true }).click();
await page.getByPlaceholder("搜索笔记").fill(marker);
await page.locator(`[data-memo-id="${memoId}"]`).locator("button").first().click();
await page.getByRole("button", { name: "清空搜索", exact: true }).click();
const editor = page.locator(".ProseMirror[contenteditable='true']");
const firstParagraph = editor.locator("p").first();
await expect(firstParagraph).toContainText(`First paragraph ${marker}`);
await firstParagraph.click();
await page.keyboard.press("End");
await page.keyboard.insertText(" first-save");
await waitForQueuedContent(page, memoId, "first-save");
await page.evaluate(() => window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")));
await firstUpdateStarted;
await firstParagraph.click();
await page.keyboard.press("End");
await page.keyboard.insertText(" typed-during-sync");
await waitForPendingQueuedContent(page, memoId, "typed-during-sync");
const selectionBeforeAcknowledgement = await readEditorSelection(page);
expect(selectionBeforeAcknowledgement.offset).toBe(selectionBeforeAcknowledgement.paragraphText.length);
const firstSyncCompleted = waitForSyncCompleted(page);
releaseFirstUpdate();
await firstSyncCompleted;
await expect.poll(async () => {
const item = (await readSyncQueue(page)).find((candidate) => candidate.kind === "memo.update" && candidate.memoId === memoId);
return item ? JSON.stringify(item.payload) : "";
}).toContain("typed-during-sync");
expect(await readEditorSelection(page)).toEqual(selectionBeforeAcknowledgement);
await page.keyboard.insertText(" after-ack");
await expect(firstParagraph).toContainText("typed-during-sync after-ack");
await expect(editor.locator("p").last()).not.toContainText("after-ack");
await waitForPendingQueuedContent(page, memoId, "after-ack");
const selectionBeforeCompletedSync = await readEditorSelection(page);
const secondUpdateResponse = page.waitForResponse(
(response) => response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/v1/memos/${memoId}`,
);
const secondSyncCompleted = waitForSyncCompleted(page);
await page.evaluate(() => window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed")));
expect((await secondUpdateResponse).ok()).toBe(true);
await secondSyncCompleted;
expect(await readEditorSelection(page)).toEqual(selectionBeforeCompletedSync);
await expect.poll(async () => (await readSyncQueue(page)).some(
(item) => item.memoId === memoId && item.status === "conflict",
)).toBe(false);
await expect.poll(async () => {
const response = await page.request.get(`/api/v1/memos/${memoId}`);
const body = await response.json() as { memo: { contentJson: unknown } };
return JSON.stringify(body.memo.contentJson);
}).toContain("typed-during-sync after-ack");
} finally {
await page.unroute(`**/api/v1/memos/${memoId}`);
await page.request.delete(`/api/v1/memos/${memoId}`);
await page.request.delete(`/api/v1/memos/${memoId}?permanent=1`);
}
});
+3 -1
View File
@@ -134,9 +134,11 @@ describe("web sync queue concurrency", () => {
const result = await runningSync;
const queued = await localDb.syncQueue.get(getMemoUpdateQueueId("memo_sync_test"));
expect(result).toEqual({ attempted: 1, synced: 0, failed: 0, conflicted: 0 });
expect(result).toEqual({ attempted: 1, synced: 1, failed: 0, conflicted: 0 });
expect(queued?.status).toBe("pending");
expect(queued?.payload.title).toBe("newer text");
expect(queued?.payload.expectedRevision).toBe(1);
expect(queued?.payload.expectedContentHash).toBe("hash-1");
});
test("keeps a genuine server revision mismatch as a conflict", async () => {