From 1a1429cde0a129af8c60ea99196b83d277fa95d0 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 4 Sep 2026 16:40:20 +0800 Subject: [PATCH] fix(desktop): keep new note editor focused during sync --- apps/web/src/components/EditorPane.tsx | 18 ++++- .../editor/editor-instance-identity.test.mjs | 26 ++++++++ .../editor/editor-instance-identity.ts | 11 ++++ tests/e2e/new-memo-sync.spec.ts | 65 ++++++++++++++++--- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 3747fcf1..de14ed4c 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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(null); const editingMemoIdRef = useRef(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]); diff --git a/apps/web/src/components/editor/editor-instance-identity.test.mjs b/apps/web/src/components/editor/editor-instance-identity.test.mjs index 9658fb5b..b66a27c6 100644 --- a/apps/web/src/components/editor/editor-instance-identity.test.mjs +++ b/apps/web/src/components/editor/editor-instance-identity.test.mjs @@ -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"); diff --git a/apps/web/src/components/editor/editor-instance-identity.ts b/apps/web/src/components/editor/editor-instance-identity.ts index f872e4ae..603ce8df 100644 --- a/apps/web/src/components/editor/editor-instance-identity.ts +++ b/apps/web/src/components/editor/editor-instance-identity.ts @@ -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, diff --git a/tests/e2e/new-memo-sync.spec.ts b/tests/e2e/new-memo-sync.spec.ts index 8ad0d93d..51d99e46 100644 --- a/tests/e2e/new-memo-sync.spec.ts +++ b/tests/e2e/new-memo-sync.spec.ts @@ -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((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(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();