From 87980a3854c1fdec871a29b59225dd7de824ca8b Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 29 Aug 2026 21:13:39 +0800 Subject: [PATCH] fix(desktop): prevent editor reload after note creation --- apps/web/src/hooks/useWorkspaceQueuedSync.ts | 8 +++- apps/web/src/lib/workspace-refresh.test.mjs | 43 ++++++++++++++++++++ apps/web/src/lib/workspace-refresh.ts | 37 +++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/web/src/hooks/useWorkspaceQueuedSync.ts b/apps/web/src/hooks/useWorkspaceQueuedSync.ts index 75908849..dbbfe97c 100644 --- a/apps/web/src/hooks/useWorkspaceQueuedSync.ts +++ b/apps/web/src/hooks/useWorkspaceQueuedSync.ts @@ -5,7 +5,7 @@ import { isBrowserOffline } from "@/lib/network-status"; import { emptySyncQueueSummary, type SyncQueueSummary } from "@/lib/sync-queue"; import { notifyMemoIdRemapped, notifyMemoSyncAcknowledged } from "@/lib/sync-events"; import { observeLocalMemoIdMappings, putLocalMemo, replaceLocalMemoId } from "@/lib/local-mirror"; -import { resolveSyncedMemoId } from "@/lib/workspace-refresh"; +import { preserveRemappedMemoDetailQueries, resolveSyncedMemoId } from "@/lib/workspace-refresh"; type UseWorkspaceQueuedSyncOptions = { isOnline: boolean; @@ -34,6 +34,10 @@ export const useWorkspaceQueuedSync = ({ const applyMemoIdMappings = useCallback((memoIdMappings: ReadonlyMap) => { if (memoIdMappings.size === 0) return; + // Seed the durable-id query before changing the selection. The desktop + // editor otherwise renders its loading state between the local-id and + // remote-id queries, which looks like a full editor reload after create. + preserveRemappedMemoDetailQueries(queryClient, memoIdMappings); notifyMemoIdRemapped(memoIdMappings); const selectedMemoId = selectedMemoIdRef.current; const pendingMemoId = pendingCreatedMemoIdRef.current; @@ -51,7 +55,7 @@ export const useWorkspaceQueuedSync = ({ selectedMemoIdRef.current = nextSelectedMemoId; setSelectedMemoId(nextSelectedMemoId); } - }, [pendingCreatedMemoIdRef, selectedMemoIdRef, setCreatedMemoEditId, setSelectedMemoId]); + }, [pendingCreatedMemoIdRef, queryClient, selectedMemoIdRef, setCreatedMemoEditId, setSelectedMemoId]); useEffect(() => observeLocalMemoIdMappings(localDataScope, applyMemoIdMappings), [applyMemoIdMappings, localDataScope]); diff --git a/apps/web/src/lib/workspace-refresh.test.mjs b/apps/web/src/lib/workspace-refresh.test.mjs index 21e6d29c..3e7ed32f 100644 --- a/apps/web/src/lib/workspace-refresh.test.mjs +++ b/apps/web/src/lib/workspace-refresh.test.mjs @@ -1,5 +1,6 @@ import { strict as assert } from "node:assert"; import { describe, it } from "node:test"; +import { QueryClient } from "@tanstack/react-query"; import { BACKGROUND_WORKSPACE_REFRESH_INTERVAL_MS, claimBackgroundRefreshLease, @@ -7,6 +8,7 @@ import { DEFERRED_MEMO_SYNC_DELAY_MS, releaseBackgroundRefreshLease, refreshWorkspaceData, + preserveRemappedMemoDetailQueries, resolveCreatedMemoSelection, resolveSyncedMemoId, shouldNavigateHomeWhenOpeningMemo, @@ -76,6 +78,47 @@ describe("refreshWorkspaceData", () => { assert.equal(resolveSyncedMemoId(mappings, null), null); }); + it("preserves the active memo detail while desktop sync changes its query key", () => { + const queryClient = new QueryClient(); + const temporaryMemo = { + id: "memo_local_1", + title: "", + contentMarkdown: "", + tags: [], + }; + queryClient.setQueryData(["memo", temporaryMemo.id, "notebook"], { memo: temporaryMemo }); + + preserveRemappedMemoDetailQueries( + queryClient, + new Map([[temporaryMemo.id, "memo_remote_1"]]), + ); + + assert.deepEqual( + queryClient.getQueryData(["memo", "memo_remote_1", "notebook"]), + { memo: { ...temporaryMemo, id: "memo_remote_1" } }, + ); + }); + + it("does not replace an already cached remote memo during id handoff", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["memo", "memo_local_1", "notebook"], { + memo: { id: "memo_local_1", title: "local", contentMarkdown: "", tags: [] }, + }); + queryClient.setQueryData(["memo", "memo_remote_1", "notebook"], { + memo: { id: "memo_remote_1", title: "remote", contentMarkdown: "", tags: [] }, + }); + + preserveRemappedMemoDetailQueries( + queryClient, + new Map([["memo_local_1", "memo_remote_1"]]), + ); + + assert.equal( + queryClient.getQueryData(["memo", "memo_remote_1", "notebook"]).memo.title, + "remote", + ); + }); + it("remaps a created memo selection without closing over the selected memo state", () => { assert.equal(resolveCreatedMemoSelection("memo_local_1", null, "memo_local_1", "memo_remote_1"), "memo_remote_1"); assert.equal(resolveCreatedMemoSelection("memo_other", "memo_local_1", "memo_local_1", "memo_remote_1"), "memo_remote_1"); diff --git a/apps/web/src/lib/workspace-refresh.ts b/apps/web/src/lib/workspace-refresh.ts index bc690b3e..b023f8f4 100644 --- a/apps/web/src/lib/workspace-refresh.ts +++ b/apps/web/src/lib/workspace-refresh.ts @@ -1,3 +1,6 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { MemoDetail } from "@edgeever/shared"; + export type WorkspaceRefreshMode = "background" | "manual"; export type WorkspaceMemoView = "notebook" | "trash"; @@ -86,6 +89,40 @@ export const resolveSyncedMemoId = ( memoId: string | null, ) => memoId ? memoIdMappings.get(memoId) ?? memoId : null; +type MemoDetailQueryData = { memo: MemoDetail }; + +/** + * Keep the active editor backed by cached detail while sync replaces a local + * memo id with its durable server id. Without this handoff, React Query briefly + * observes an empty query under the new key and swaps the editor for its loading + * state until the desktop sidecar returns the same memo again. + */ +export const preserveRemappedMemoDetailQueries = ( + queryClient: Pick, + memoIdMappings: ReadonlyMap, +) => { + for (const [temporaryId, remoteId] of memoIdMappings) { + if (temporaryId === remoteId) continue; + + for (const view of ["notebook", "trash"] as const) { + const remoteKey = ["memo", remoteId, view] as const; + if (queryClient.getQueryData(remoteKey)?.memo) continue; + + const temporary = queryClient.getQueryData([ + "memo", + temporaryId, + view, + ]); + if (!temporary?.memo) continue; + + queryClient.setQueryData(remoteKey, { + ...temporary, + memo: { ...temporary.memo, id: remoteId }, + }); + } + } +}; + export const resolveCreatedMemoSelection = ( currentMemoId: string | null, pendingMemoId: string | null,