refactor(web): extract editor save conflict handling

Move save-failure classification, save chrome, and conflict actions out of EditorPane so queueing, conflict banners, and adopt/copy stay one code path.
This commit is contained in:
tianma-if committed 2026-09-15 16:43:56 +08:00
1 parent 6d6b1704a0
commit 493fbbd7d4
6 files changed
+343 -150

No files matched your search

+88 -150
View File
@@ -130,13 +130,9 @@ import { createEdgeEverMathematics } from "@edgeever/shared/mathematics";
import { codeBlockLowlight, EdgeEverCodeBlock } from "@/lib/code-block";
import { compressImageForUpload } from "@/lib/image-compression";
import { LOCAL_DATABASE_INTERRUPTED_EVENT, localDb, selectNewestLocalDraft, type MemoUpdateSyncPayload } from "@/lib/local-db";
import { LocalDatabaseUnavailableError } from "@/lib/local-database-recovery";
import { persistEmergencyDraft, readEmergencyDraft, removeEmergencyDraft } from "@/lib/emergency-draft";
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue";
import { getMemoUpdateQueueId, isMemoUpdateAlreadyApplied, queueMemoUpdate } from "@/lib/sync-queue";
import {
formatLocalDraftClipboardText,
formatMemoSaveConflictReason,
getMemoSaveConflictInfo,
getMemoSaveConflictInfoFromQueueItem,
} from "@/lib/memo-save-conflict";
import { copyTextToClipboard } from "@/lib/clipboard";
@@ -226,6 +222,9 @@ import { createInlineFieldExtension } from "./editor/InlineField";
import { createPluginEmbedExtension } from "./editor/PluginEmbed";
import { getEditorScrollProgress, restoreEditorScrollProgress } from "./editor/editor-mode-scroll";
import { useEditorSaveStatus } from "./editor/useEditorSaveStatus";
import { getEditorSaveChrome } from "./editor/editor-save-chrome";
import { classifyEditorSaveFailure, shouldLeaveEditorAfterSaveError } from "./editor/editor-save-failure";
import { useEditorSaveConflictActions } from "./editor/useEditorSaveConflictActions";
import { useEditorNoteSearchController } from "./editor/useEditorNoteSearchController";
import { EditorNoteLinkPicker } from "./editor/EditorNoteLinkPicker";
import { EditorResourceDialogs } from "./editor/EditorResourceDialogs";
@@ -413,8 +412,6 @@ const RichEditorPane = ({
setSaveConflictInfo,
setSaveState,
} = useEditorSaveStatus();
const [conflictActionPending, setConflictActionPending] = useState<"adopt" | "copy" | null>(null);
const [conflictActionMessage, setConflictActionMessage] = useState<string | null>(null);
const [storageSaveError, setStorageSaveError] = useState(false);
const [hydratedEditorMemoId, setHydratedEditorMemoId] = useState<string | null>(null);
@@ -2864,32 +2861,31 @@ const RichEditorPane = ({
setSaveState("idle");
},
onError: async (error) => {
if (error instanceof LocalDatabaseUnavailableError) {
const failure = classifyEditorSaveFailure(error);
if (failure.kind === "storage") {
setStorageSaveError(true);
setSaveConflictInfo(null);
setSaveState("error");
return;
}
setStorageSaveError(false);
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
const conflictInfo = getMemoSaveConflictInfo(sourceError);
if (conflictInfo) {
setSaveConflictInfo(conflictInfo);
if (failure.kind === "conflict") {
setSaveConflictInfo(failure.conflict);
setSaveState("conflict");
return;
}
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
await queueMemoUpdate(error.payload);
if (failure.kind === "queue") {
await queueMemoUpdate(failure.payload);
await localDb.drafts.put({
memoId: error.payload.memoId,
title: error.payload.title,
tagsText: error.tagsText,
contentJson: error.payload.contentJson,
memoId: failure.payload.memoId,
title: failure.payload.title,
tagsText: failure.tagsText,
contentJson: failure.payload.contentJson,
updatedAt: new Date().toISOString(),
});
removeEmergencyDraft(error.payload.memoId);
removeEmergencyDraft(failure.payload.memoId);
setHasUnsavedChanges(false);
setSaveConflictInfo(null);
@@ -3273,19 +3269,6 @@ const RichEditorPane = ({
return () => window.clearTimeout(timer);
}, [dirtyVersion, editor, hasUnsavedChanges, memo, mutateSave, saveMutationPending, saveState, useMobilePlainTextEditor]);
// Must stay above early returns so hook order never changes across loading/empty/editor states.
const saveConflictReason = useMemo(
() => (saveState === "conflict" ? formatMemoSaveConflictReason(t, saveConflictInfo) : null),
[saveConflictInfo, saveState, t],
);
useEffect(() => {
if (saveState !== "conflict") {
setConflictActionPending(null);
setConflictActionMessage(null);
}
}, [saveState]);
const getLocalDraftMarkdown = useCallback(() => {
if (useMobilePlainTextEditor) {
return getMobilePlainTextValue();
@@ -3307,108 +3290,83 @@ const RichEditorPane = ({
useMobilePlainTextEditor,
]);
const handleCopyLocalDraft = useCallback(async () => {
if (conflictActionPending) {
return;
}
setConflictActionPending("copy");
setConflictActionMessage(null);
try {
const text = formatLocalDraftClipboardText({
title,
tags: parseTagsText(tagsText),
contentMarkdown: getLocalDraftMarkdown(),
});
const copied = await copyTextToClipboard(text);
if (!copied) {
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
return;
}
setConflictActionMessage(t("editor.saveState.conflictCopyDraftDone"));
window.setTimeout(() => {
setConflictActionMessage((current) =>
current === t("editor.saveState.conflictCopyDraftDone") ? null : current
);
}, 2000);
} catch {
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
} finally {
setConflictActionPending(null);
}
}, [conflictActionPending, getLocalDraftMarkdown, t, tagsText, title]);
const handleAdoptCloudAndReload = useCallback(async () => {
const adoptCloudMemo = useCallback(async () => {
const currentMemo = memoRef.current;
if (!currentMemo || conflictActionPending === "adopt") {
if (!currentMemo) {
return;
}
setConflictActionPending("adopt");
setConflictActionMessage(null);
try {
const { memo: remoteMemo } = await repository.adoptCloudMemo(currentMemo.id);
await onSaved(remoteMemo);
const { memo: remoteMemo } = await repository.adoptCloudMemo(currentMemo.id);
await onSaved(remoteMemo);
setHasUnsavedChanges(false);
setSaveConflictInfo(null);
setSaveState("idle");
setConflictActionMessage(null);
setHasUnsavedChanges(false);
setSaveConflictInfo(null);
setSaveState("idle");
const nextTitle = getEditableMemoTitle(remoteMemo.title);
const nextTagsText = remoteMemo.tags.join(", ");
const nextContent = resolveMemoContentDoc(remoteMemo.contentJson, remoteMemo.contentMarkdown);
const nextMarkdown = remoteMemo.contentMarkdown || docToMarkdown(nextContent);
const nextTitle = getEditableMemoTitle(remoteMemo.title);
const nextTagsText = remoteMemo.tags.join(", ");
const nextContent = resolveMemoContentDoc(remoteMemo.contentJson, remoteMemo.contentMarkdown);
const nextMarkdown = remoteMemo.contentMarkdown || docToMarkdown(nextContent);
memoRef.current = remoteMemo;
editSessionRef.current = null;
hydratedMemoIdRef.current = remoteMemo.id;
setHydratedEditorMemoId(remoteMemo.id);
editingMemoIdRef.current = remoteMemo.id;
appliedEditorSourceKeyRef.current = `memo:${remoteMemo.id}:${remoteMemo.revision}:${remoteMemo.updatedAt}:${remoteMemo.contentHash}:${nextTitle}:${nextTagsText}:${nextMarkdown}`;
memoRef.current = remoteMemo;
editSessionRef.current = null;
hydratedMemoIdRef.current = remoteMemo.id;
setHydratedEditorMemoId(remoteMemo.id);
editingMemoIdRef.current = remoteMemo.id;
appliedEditorSourceKeyRef.current = `memo:${remoteMemo.id}:${remoteMemo.revision}:${remoteMemo.updatedAt}:${remoteMemo.contentHash}:${nextTitle}:${nextTagsText}:${nextMarkdown}`;
setTitle(nextTitle);
setTagsText(nextTagsText);
setMobilePlainText(nextMarkdown);
setMarkdownSource(nextMarkdown);
markdownModeSnapshotRef.current = isMarkdownMode
? createMarkdownModeSnapshot(remoteMemo.id, nextContent, nextMarkdown)
: null;
setMobilePlainTextElementValue(mobileTextAreaRef.current, nextMarkdown);
setTitle(nextTitle);
setTagsText(nextTagsText);
setMobilePlainText(nextMarkdown);
setMarkdownSource(nextMarkdown);
markdownModeSnapshotRef.current = isMarkdownMode
? createMarkdownModeSnapshot(remoteMemo.id, nextContent, nextMarkdown)
: null;
setMobilePlainTextElementValue(mobileTextAreaRef.current, nextMarkdown);
const currentEditor = editorRef.current;
if (isEditorReady(currentEditor)) {
hydratingRef.current = true;
try {
currentEditor.commands.setContent(nextContent);
} catch (err) {
console.error("Failed to apply cloud memo after conflict resolve:", err);
currentEditor.commands.setContent(markdownToDoc(nextMarkdown));
}
window.setTimeout(() => {
hydratingRef.current = false;
}, 0);
const currentEditor = editorRef.current;
if (isEditorReady(currentEditor)) {
hydratingRef.current = true;
try {
currentEditor.commands.setContent(nextContent);
} catch (err) {
console.error("Failed to apply cloud memo after conflict resolve:", err);
currentEditor.commands.setContent(markdownToDoc(nextMarkdown));
}
if (requiresLocalEditSession(remoteMemo)) {
editSessionRef.current = createLocalEditSession(remoteMemo);
} else {
void api.createMemoEditSession(remoteMemo.id).then((response) => {
if (editingMemoIdRef.current !== remoteMemo.id) return;
editSessionRef.current = response.editSession;
}).catch(() => {
if (editingMemoIdRef.current !== remoteMemo.id) return;
editSessionRef.current = createLocalEditSession(remoteMemo);
});
}
await queryClient.invalidateQueries({ queryKey: ["memo", remoteMemo.id] });
} catch {
setConflictActionMessage(t("editor.saveState.conflictAdoptFailed"));
} finally {
setConflictActionPending(null);
window.setTimeout(() => {
hydratingRef.current = false;
}, 0);
}
}, [conflictActionPending, isMarkdownMode, onSaved, queryClient, repository, t]);
if (requiresLocalEditSession(remoteMemo)) {
editSessionRef.current = createLocalEditSession(remoteMemo);
} else {
void api.createMemoEditSession(remoteMemo.id).then((response) => {
if (editingMemoIdRef.current !== remoteMemo.id) return;
editSessionRef.current = response.editSession;
}).catch(() => {
if (editingMemoIdRef.current !== remoteMemo.id) return;
editSessionRef.current = createLocalEditSession(remoteMemo);
});
}
await queryClient.invalidateQueries({ queryKey: ["memo", remoteMemo.id] });
}, [isMarkdownMode, onSaved, queryClient, repository]);
const {
conflictActionMessage,
conflictActionPending,
handleAdoptCloudAndReload,
handleCopyLocalDraft,
saveConflictReason,
} = useEditorSaveConflictActions({
adoptCloudMemo,
getLocalDraftMarkdown,
saveConflictInfo,
saveState,
tagsText,
title,
});
if (isSelectionMode) {
return (
@@ -3443,29 +3401,11 @@ const RichEditorPane = ({
);
}
const saveLabel =
saveState === "saving"
? t("editor.saveState.saving")
: saveState === "saved"
? t("editor.saveState.saved")
: saveState === "queued"
? t("editor.saveState.queued")
: saveState === "conflict"
? t("editor.saveState.conflict")
: saveState === "error"
? t("editor.saveState.error")
: hasUnsavedChanges
? t("editor.saveState.unsaved")
: t("editor.saveState.saved");
const saveStateClassName =
saveState === "error" || saveState === "conflict"
? "bg-rose-50 text-rose-700"
: saveState === "queued"
? "bg-slate-50 text-slate-400"
: saveState === "saving" || hasUnsavedChanges
? "bg-emerald-50 text-emerald-700"
: "bg-slate-100 text-slate-500";
const { saveLabel, saveStateClassName } = getEditorSaveChrome({
hasUnsavedChanges,
saveState,
t,
});
const imageUploadLabel =
imageUploadState === "error"
@@ -3577,8 +3517,7 @@ const RichEditorPane = ({
onBackToList();
},
onError: (error) => {
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
if (shouldLeaveEditorAfterSaveError(error)) {
onMobileDefaultEditConsumed();
onBackToList();
}
@@ -3601,8 +3540,7 @@ const RichEditorPane = ({
setMobileToolbarOpen(false);
},
onError: (error) => {
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
if (shouldLeaveEditorAfterSaveError(error)) {
onMobileDefaultEditConsumed();
setIsMobileEditing(false);
setMobileToolbarOpen(false);
@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import { getEditorSaveChrome } from "./editor-save-chrome.ts";
const t = (key) => key;
describe("getEditorSaveChrome", () => {
test("keeps conflict chrome over unsaved edits", () => {
expect(getEditorSaveChrome({ saveState: "conflict", hasUnsavedChanges: true, t })).toEqual({
saveLabel: "editor.saveState.conflict",
saveStateClassName: "bg-rose-50 text-rose-700",
});
});
test("shows queued chrome while the outbox holds the save", () => {
expect(getEditorSaveChrome({ saveState: "queued", hasUnsavedChanges: false, t }).saveLabel).toBe(
"editor.saveState.queued",
);
});
test("marks dirty idle saves as unsaved", () => {
expect(getEditorSaveChrome({ saveState: "idle", hasUnsavedChanges: true, t }).saveLabel).toBe(
"editor.saveState.unsaved",
);
});
});
@@ -0,0 +1,39 @@
import type { EditorSavePhase } from "./useEditorSaveStatus";
type Translate = (key: string) => string;
export const getEditorSaveChrome = ({
saveState,
hasUnsavedChanges,
t,
}: {
saveState: EditorSavePhase;
hasUnsavedChanges: boolean;
t: Translate;
}) => {
const saveLabel =
saveState === "saving"
? t("editor.saveState.saving")
: saveState === "saved"
? t("editor.saveState.saved")
: saveState === "queued"
? t("editor.saveState.queued")
: saveState === "conflict"
? t("editor.saveState.conflict")
: saveState === "error"
? t("editor.saveState.error")
: hasUnsavedChanges
? t("editor.saveState.unsaved")
: t("editor.saveState.saved");
const saveStateClassName =
saveState === "error" || saveState === "conflict"
? "bg-rose-50 text-rose-700"
: saveState === "queued"
? "bg-slate-50 text-slate-400"
: saveState === "saving" || hasUnsavedChanges
? "bg-emerald-50 text-emerald-700"
: "bg-slate-100 text-slate-500";
return { saveLabel, saveStateClassName };
};
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import { ApiRequestError } from "@edgeever/client";
import { LocalDatabaseUnavailableError } from "@/lib/local-database-recovery";
import { MemoSaveRequestError } from "./editor-pane-helpers.ts";
import { classifyEditorSaveFailure, shouldLeaveEditorAfterSaveError } from "./editor-save-failure.ts";
const queuedPayload = {
memoId: "memo-1",
expectedRevision: 1,
expectedContentHash: "base",
editSessionId: "session-1",
title: "Draft",
contentJson: { type: "doc", content: [] },
contentMarkdown: "hi",
tags: [],
};
describe("classifyEditorSaveFailure", () => {
test("treats local storage outages as a retryable storage failure", () => {
expect(classifyEditorSaveFailure(new LocalDatabaseUnavailableError(new Error("idb")))).toEqual({
kind: "storage",
});
});
test("keeps revision conflicts visible instead of queueing them", () => {
const error = new ApiRequestError("Conflict", 409, "revision_conflict", {
expectedRevision: 3,
currentRevision: 4,
});
expect(classifyEditorSaveFailure(error)).toEqual({
kind: "conflict",
conflict: {
code: "revision_conflict",
details: {
expectedRevision: 3,
currentRevision: 4,
expectedContentHash: undefined,
currentContentHash: undefined,
source: undefined,
},
},
});
expect(shouldLeaveEditorAfterSaveError(error)).toBe(false);
});
test("queues network-like save failures so leaving the editor can proceed", () => {
const error = new MemoSaveRequestError(new TypeError("Failed to fetch"), queuedPayload, "tag");
expect(classifyEditorSaveFailure(error)).toMatchObject({
kind: "queue",
tagsText: "tag",
payload: queuedPayload,
});
expect(shouldLeaveEditorAfterSaveError(error)).toBe(true);
});
test("falls back to a generic error for unknown failures", () => {
expect(classifyEditorSaveFailure(new Error("boom"))).toEqual({ kind: "error" });
});
});
@@ -0,0 +1,32 @@
import { LocalDatabaseUnavailableError } from "@/lib/local-database-recovery";
import { getMemoSaveConflictInfo, type MemoSaveConflictInfo } from "@/lib/memo-save-conflict";
import { shouldQueueMemoSaveError } from "@/lib/sync-queue";
import type { MemoUpdateSyncPayload } from "@/lib/local-db";
import { MemoSaveRequestError } from "./editor-pane-helpers";
export type EditorSaveFailure =
| { kind: "storage" }
| { kind: "conflict"; conflict: MemoSaveConflictInfo }
| { kind: "queue"; payload: MemoUpdateSyncPayload; tagsText: string }
| { kind: "error" };
export const classifyEditorSaveFailure = (error: unknown): EditorSaveFailure => {
if (error instanceof LocalDatabaseUnavailableError) {
return { kind: "storage" };
}
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
const conflict = getMemoSaveConflictInfo(sourceError);
if (conflict) {
return { kind: "conflict", conflict };
}
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
return { kind: "queue", payload: error.payload, tagsText: error.tagsText };
}
return { kind: "error" };
};
export const shouldLeaveEditorAfterSaveError = (error: unknown) =>
classifyEditorSaveFailure(error).kind === "queue";
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyTextToClipboard } from "@/lib/clipboard";
import { parseTagsText } from "@/lib/utils";
import {
formatLocalDraftClipboardText,
formatMemoSaveConflictReason,
type MemoSaveConflictInfo,
} from "@/lib/memo-save-conflict";
import type { EditorSavePhase } from "./useEditorSaveStatus";
export type EditorConflictAction = "adopt" | "copy";
export const useEditorSaveConflictActions = ({
saveState,
saveConflictInfo,
title,
tagsText,
getLocalDraftMarkdown,
adoptCloudMemo,
}: {
saveState: EditorSavePhase;
saveConflictInfo: MemoSaveConflictInfo | null;
title: string;
tagsText: string;
getLocalDraftMarkdown: () => string;
adoptCloudMemo: () => Promise<void>;
}) => {
const { t } = useTranslation();
const [conflictActionPending, setConflictActionPending] = useState<EditorConflictAction | null>(null);
const [conflictActionMessage, setConflictActionMessage] = useState<string | null>(null);
const saveConflictReason = useMemo(
() => (saveState === "conflict" ? formatMemoSaveConflictReason(t, saveConflictInfo) : null),
[saveConflictInfo, saveState, t],
);
useEffect(() => {
if (saveState !== "conflict") {
setConflictActionPending(null);
setConflictActionMessage(null);
}
}, [saveState]);
const handleCopyLocalDraft = useCallback(async () => {
if (conflictActionPending) {
return;
}
setConflictActionPending("copy");
setConflictActionMessage(null);
try {
const text = formatLocalDraftClipboardText({
title,
tags: parseTagsText(tagsText),
contentMarkdown: getLocalDraftMarkdown(),
});
const copied = await copyTextToClipboard(text);
if (!copied) {
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
return;
}
setConflictActionMessage(t("editor.saveState.conflictCopyDraftDone"));
window.setTimeout(() => {
setConflictActionMessage((current) =>
current === t("editor.saveState.conflictCopyDraftDone") ? null : current
);
}, 2000);
} catch {
setConflictActionMessage(t("editor.saveState.conflictCopyDraftFailed"));
} finally {
setConflictActionPending(null);
}
}, [conflictActionPending, getLocalDraftMarkdown, t, tagsText, title]);
const handleAdoptCloudAndReload = useCallback(async () => {
if (conflictActionPending === "adopt") {
return;
}
setConflictActionPending("adopt");
setConflictActionMessage(null);
try {
await adoptCloudMemo();
setConflictActionMessage(null);
} catch {
setConflictActionMessage(t("editor.saveState.conflictAdoptFailed"));
} finally {
setConflictActionPending(null);
}
}, [adoptCloudMemo, conflictActionPending, t]);
return {
conflictActionMessage,
conflictActionPending,
handleAdoptCloudAndReload,
handleCopyLocalDraft,
saveConflictReason,
};
};