fix(desktop): keep new note editor focused during sync

This commit is contained in:
tianma-if committed 2026-09-04 16:40:20 +08:00
1 parent 2d703a9ed5
commit 1a1429cde0
4 files changed
+108 -12

No files matched your search

+16 -2
View File
@@ -61,6 +61,7 @@ import { EditorTagPicker } from "./EditorTagPicker";
import { useAiBubbleMenu } from "./editor/useAiBubbleMenu";
import {
createEditorInstanceMemoIdentity,
isEditorInstanceHydratedForMemo,
reconcileEditorInstanceMemoIdentity,
remapEditorInstanceMemoIdentity,
} from "./editor/editor-instance-identity";
@@ -866,6 +867,11 @@ const RichEditorPane = ({
memo?.id ?? null,
);
const editorInstanceMemoKey = editorInstanceMemoIdentityRef.current.instanceKey;
const editorIsHydratedForCurrentMemo = isEditorInstanceHydratedForMemo(
editorInstanceMemoIdentityRef.current,
hydratedEditorMemoId,
memo?.id ?? null,
);
/** Last content source applied to the editor — used to skip redundant setContent. */
const appliedEditorSourceKeyRef = useRef<string | null>(null);
const editingMemoIdRef = useRef<string | null>(memo?.id ?? null);
@@ -1228,7 +1234,7 @@ const RichEditorPane = ({
content: memo
? resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown)
: { type: "doc", content: [{ type: "paragraph" }] },
editable: Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id),
editable: Boolean(memo && !effectiveReadOnly && editorIsHydratedForCurrentMemo),
editorProps: {
attributes: {
class: "edgeever-note-rich-editor prose prose-slate max-w-none focus:outline-none min-h-[240px] px-4 py-3 sm:px-7 lg:min-h-[180px]",
@@ -2429,7 +2435,15 @@ const RichEditorPane = ({
useEffect(() => {
if (isEditorReady(editor)) {
editor.setEditable(Boolean(memo && !effectiveReadOnly && hydratedEditorMemoId === memo.id));
editor.setEditable(Boolean(
memo
&& !effectiveReadOnly
&& isEditorInstanceHydratedForMemo(
editorInstanceMemoIdentityRef.current,
hydratedEditorMemoId,
memo.id,
)
));
}
}, [editor, effectiveReadOnly, hydratedEditorMemoId, memo]);
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
createEditorInstanceMemoIdentity,
isEditorInstanceHydratedForMemo,
reconcileEditorInstanceMemoIdentity,
remapEditorInstanceMemoIdentity,
} from "./editor-instance-identity.ts";
@@ -35,6 +36,31 @@ describe("editor instance memo identity", () => {
expect(secondIdentity.instanceKey).not.toBe(firstIdentity.instanceKey);
});
test("keeps the editor hydrated while desktop sync announces the durable id before the parent rerenders", () => {
const localIdentity = createEditorInstanceMemoIdentity("local_memo_1");
const remappedIdentity = remapEditorInstanceMemoIdentity(
localIdentity,
new Map([["local_memo_1", "memo_remote_1"]]),
);
expect(isEditorInstanceHydratedForMemo(
remappedIdentity,
"memo_remote_1",
"local_memo_1",
)).toBe(true);
expect(isEditorInstanceHydratedForMemo(
remappedIdentity,
"memo_remote_1",
"memo_remote_1",
)).toBe(true);
});
test("does not treat a genuinely different memo as hydrated", () => {
const identity = createEditorInstanceMemoIdentity("memo_1");
expect(isEditorInstanceHydratedForMemo(identity, "memo_1", "memo_2")).toBe(false);
});
test("ignores id mappings for a different memo", () => {
const identity = createEditorInstanceMemoIdentity("memo_1");
@@ -21,6 +21,17 @@ export const reconcileEditorInstanceMemoIdentity = (
? identity
: createEditorInstanceMemoIdentity(memoId);
export const isEditorInstanceHydratedForMemo = (
identity: EditorInstanceMemoIdentity,
hydratedMemoId: string | null,
renderedMemoId: string | null,
) => Boolean(
hydratedMemoId
&& renderedMemoId
&& identity.aliases.has(hydratedMemoId)
&& identity.aliases.has(renderedMemoId)
);
export const remapEditorInstanceMemoIdentity = (
identity: EditorInstanceMemoIdentity,
memoIdMappings: ReadonlyMap<string, string>,
+55 -10
View File
@@ -61,6 +61,35 @@ const holdNextMemoCreate = async (page: Page) => {
return { createResponse, createStarted, releaseCreate };
};
const holdNextMemoCreateResponse = async (page: Page) => {
let releaseCreate!: () => void;
let resolveCreatedMemo!: (memo: { id: string }) => void;
const createGate = new Promise<void>((resolve) => {
releaseCreate = resolve;
});
const createdMemo = new Promise<{ id: string }>((resolve) => {
resolveCreatedMemo = resolve;
});
const createResponse = page.waitForResponse(
(response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/v1/memos",
);
await page.route("**/api/v1/memos", async (route: Route) => {
if (route.request().method() !== "POST") {
await route.continue();
return;
}
const response = await route.fetch();
const created = await response.json() as { memo: { id: string } };
resolveCreatedMemo(created.memo);
await createGate;
await route.fulfill({ response });
});
return { createdMemo, createResponse, releaseCreate };
};
const editNewMemo = async (page: Page, title: string, content: string) => {
await page.getByRole("button", { name: "新建笔记", exact: true }).click();
@@ -177,14 +206,22 @@ const finishSyncAndVerifyReload = async (
};
test.describe("new memo synchronization", () => {
test("keeps the new memo editor mounted and focused while its local id is remapped", async ({ page }) => {
const heldCreate = await holdNextMemoCreate(page);
test("keeps the new memo editor editable and focused when desktop sync remaps its id early", async ({ page }) => {
const heldCreate = await holdNextMemoCreateResponse(page);
let memoId: string | null = null;
try {
await page.goto("/");
await page.getByRole("button", { name: "新建笔记", exact: true }).click();
await heldCreate.createStarted;
const createdMemo = await heldCreate.createdMemo;
memoId = createdMemo.id;
let temporaryMemoId: string | null = null;
await expect.poll(async () => {
const items = await readIndexedDbStore<StoredQueueItem>(page, "syncQueue");
temporaryMemoId = items.find((item) => item.kind === "memo.create")?.memoId ?? null;
return temporaryMemoId;
}).toMatch(/^local_/);
const editor = page.locator(".ProseMirror[contenteditable='true']");
await expect(editor).toBeEditable();
@@ -234,21 +271,29 @@ test.describe("new memo synchronization", () => {
}, { once: true });
}));
heldCreate.releaseCreate();
const createResponse = await heldCreate.createResponse;
expect(createResponse.status()).toBe(201);
const created = await createResponse.json() as { memo: { id: string } };
memoId = created.memo.id;
await page.evaluate(({ localId, remoteId }) => {
window.dispatchEvent(new CustomEvent("edgeever:memo-id-remapped", {
detail: new Map([[localId, remoteId]]),
}));
}, { localId: temporaryMemoId!, remoteId: memoId });
const observation = await editorObservation;
expect(observation.mapping).toContainEqual([expect.any(String), memoId]);
expect(observation.mapping[0]?.[0]).not.toBe(memoId);
expect(observation.mapping).toContainEqual([temporaryMemoId, memoId]);
expect(observation).toMatchObject({
disconnected: false,
focusLost: false,
replaced: false,
stillFocused: true,
});
await expect(editor).toBeEditable();
await expect(editor).toBeFocused();
heldCreate.releaseCreate();
const createResponse = await heldCreate.createResponse;
expect(createResponse.status()).toBe(201);
const created = await createResponse.json() as { memo: { id: string } };
expect(created.memo.id).toBe(memoId);
await expect(editor).toBeEditable();
await expect(editor).toBeFocused();
} finally {
heldCreate.releaseCreate();