From 01c6174ffdf6f78922ce0f2d89cd9cf9e3181bd6 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Fri, 17 Jul 2026 00:27:13 +0800 Subject: [PATCH] fix: prevent search edit note duplication (#36) --- apps/web/src/components/EditorPane.tsx | 20 +++- apps/web/src/components/WorkspaceApp.tsx | 140 ++--------------------- apps/web/src/lib/memo-list-cache.ts | 139 ++++++++++++++++++++++ package.json | 2 +- tests/web-memo-list-cache.test.ts | 64 +++++++++++ 5 files changed, 232 insertions(+), 133 deletions(-) create mode 100644 apps/web/src/lib/memo-list-cache.ts create mode 100644 tests/web-memo-list-cache.test.ts diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index c6f1a52d..f848cd30 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -535,7 +535,7 @@ const MobileNativeEditorPane = ({ const persistDraft = useCallback(() => { const currentMemo = memoRef.current; - if (!currentMemo || currentMemo.isDeleted) { + if (!currentMemo || currentMemo.isDeleted || editingMemoIdRef.current !== currentMemo.id) { return; } @@ -567,7 +567,14 @@ const MobileNativeEditorPane = ({ const snapshot = currentSnapshot(); const editSession = editSessionRef.current; - if (!currentMemo || currentMemo.isDeleted || !snapshot || savingRef.current || !editSession) { + if ( + !currentMemo || + currentMemo.isDeleted || + editingMemoIdRef.current !== currentMemo.id || + !snapshot || + savingRef.current || + !editSession + ) { return false; } @@ -650,7 +657,7 @@ const MobileNativeEditorPane = ({ const markDirty = useCallback(() => { const currentMemo = memoRef.current; - if (hydratingRef.current || currentMemo?.isDeleted) { + if (hydratingRef.current || currentMemo?.isDeleted || !currentMemo || editingMemoIdRef.current !== currentMemo.id) { return; } @@ -1547,7 +1554,12 @@ const RichEditorPane = ({ const currentMemo = memoRef.current; const currentEditor = editorRef.current; - if (!currentMemo || currentMemo.isDeleted || (!useMobilePlainTextEditor && !isEditorReady(currentEditor))) { + if ( + !currentMemo || + currentMemo.isDeleted || + hydratedMemoIdRef.current !== currentMemo.id || + (!useMobilePlainTextEditor && !isEditorReady(currentEditor)) + ) { return; } diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 5f4cfae3..c8cc2e96 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -77,6 +77,7 @@ import { getNotebookMoveOptions, } from "@/lib/app-helpers"; import { useBrowserBackLayer } from "@/lib/app-hooks"; +import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache"; import type { SyncQueueSummary } from "@/lib/sync-queue"; const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; @@ -134,15 +135,6 @@ const PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => ( const memoDetailQueryKey = (memoId: string, view: MemoView) => ["memo", memoId, view] as const; -type MemoListQueryData = { - pages: Array<{ - memos: MemoSummary[]; - totalCount: number; - nextCursor: string | null; - }>; - pageParams: unknown[]; -}; - type ListNotebooksQueryData = { notebooks: Notebook[]; }; @@ -181,123 +173,6 @@ const cacheMemoDetail = (queryClient: QueryClient, memo: MemoDetail, view: MemoV queryClient.setQueryData(memoDetailQueryKey(memo.id, view), { memo }); }; -const memoMatchesFilter = (memo: MemoSummary, filterMode: unknown) => { - if (filterMode === "tagged") { - return memo.tags.length > 0; - } - - if (filterMode === "untagged") { - return memo.tags.length === 0; - } - - if (filterMode === "pinned") { - return memo.isPinned; - } - - return true; -}; - -const memoBelongsInList = (memo: MemoSummary, queryKey: readonly unknown[]) => { - const [, view, notebookId, search, filterMode] = queryKey; - const memoView = view === "trash" ? "trash" : "notebook"; - - if (memoView === "trash" !== memo.isDeleted) { - return false; - } - - if (memoView === "notebook" && typeof notebookId === "string" && notebookId && memo.notebookId !== notebookId) { - return false; - } - - if (typeof search === "string" && search.trim()) { - return false; - } - - return memoMatchesFilter(memo, filterMode); -}; - -const sortMemoSummariesForList = (memos: MemoSummary[], queryKey: readonly unknown[]) => { - const sortMode = queryKey[5]; - const sorted = [...memos]; - - if (sortMode === "title-asc") { - return sorted.sort((left, right) => { - const leftTitle = left.title?.trim() || left.excerpt || DEFAULT_MEMO_TITLE; - const rightTitle = right.title?.trim() || right.excerpt || DEFAULT_MEMO_TITLE; - return leftTitle.localeCompare(rightTitle, "zh-CN") || left.id.localeCompare(right.id); - }); - } - - if (sortMode === "created-desc") { - return sorted.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || right.id.localeCompare(left.id)); - } - - return sorted.sort((left, right) => { - if (left.isPinned !== right.isPinned) { - return left.isPinned ? -1 : 1; - } - - return Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id); - }); -}; - -const reflowMemoListPages = (current: MemoListQueryData, memos: MemoSummary[], totalCount: number) => { - let offset = 0; - - return { - ...current, - pages: current.pages.map((page) => { - const pageSize = page.memos.length; - const nextPageMemos = memos.slice(offset, offset + pageSize); - offset += pageSize; - - return { - ...page, - memos: nextPageMemos, - totalCount, - }; - }), - }; -}; - -const updateMemoSummaryInLists = (queryClient: QueryClient, memo: MemoDetail) => { - const summary = memoToSummary(memo); - - for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ["memos"] })) { - if (!current) { - continue; - } - - const flatMemos = current.pages.flatMap((page) => page.memos); - const existingIndex = flatMemos.findIndex((item) => item.id === summary.id); - const belongsInList = memoBelongsInList(summary, queryKey); - const currentTotalCount = current.pages[0]?.totalCount ?? flatMemos.length; - - if (existingIndex >= 0) { - const nextMemos = belongsInList - ? flatMemos.map((item) => (item.id === summary.id ? { ...item, ...summary } : item)) - : flatMemos.filter((item) => item.id !== summary.id); - const totalCount = belongsInList ? currentTotalCount : Math.max(0, currentTotalCount - 1); - - queryClient.setQueryData(queryKey, reflowMemoListPages(current, sortMemoSummariesForList(nextMemos, queryKey), totalCount)); - continue; - } - - if (belongsInList) { - const [firstPage, ...restPages] = current.pages; - const nextFirstPage = firstPage - ? { - ...firstPage, - memos: sortMemoSummariesForList([summary, ...firstPage.memos], queryKey), - totalCount: firstPage.totalCount + 1, - } - : { memos: [summary], totalCount: 1, nextCursor: null }; - - queryClient.setQueryData(queryKey, { ...current, pages: [nextFirstPage, ...restPages] }); - } - } -}; - const collectMemoSummariesFromCache = (queryClient: QueryClient, memoIds: Set) => { const summaries = new Map(); @@ -1403,7 +1278,7 @@ export const WorkspaceApp = ({ setSelectedNotebookId(targetNotebookId); } cacheMemoDetail(queryClient, data.memo, "notebook"); - updateMemoSummaryInLists(queryClient, data.memo); + updateMemoSummaryInLists(queryClient, memoToSummary(data.memo)); void Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), @@ -2685,9 +2560,18 @@ export const WorkspaceApp = ({ }} onSaved={async (memo) => { cacheMemoDetail(queryClient, memo, memoView); - updateMemoSummaryInLists(queryClient, memo); + updateMemoSummaryInLists(queryClient, memoToSummary(memo)); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["memos"], refetchType: "inactive" }), + ...(search.trim() + ? [ + queryClient.invalidateQueries({ + queryKey: ["memos", memoView, selectedNotebookId, search, memoFilterMode, memoSortMode], + exact: true, + refetchType: "active", + }), + ] + : []), queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }), ]); }} diff --git a/apps/web/src/lib/memo-list-cache.ts b/apps/web/src/lib/memo-list-cache.ts new file mode 100644 index 00000000..ccf32960 --- /dev/null +++ b/apps/web/src/lib/memo-list-cache.ts @@ -0,0 +1,139 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { DEFAULT_MEMO_TITLE, type MemoSummary } from "@edgeever/shared"; + +export type MemoListQueryData = { + pages: Array<{ + memos: MemoSummary[]; + totalCount: number; + nextCursor: string | null; + }>; + pageParams: unknown[]; +}; + +const memoMatchesFilter = (memo: MemoSummary, filterMode: unknown) => { + if (filterMode === "tagged") { + return memo.tags.length > 0; + } + + if (filterMode === "untagged") { + return memo.tags.length === 0; + } + + if (filterMode === "pinned") { + return memo.isPinned; + } + + return true; +}; + +const memoMatchesStaticListConstraints = (memo: MemoSummary, queryKey: readonly unknown[]) => { + const [, view, notebookId, , filterMode] = queryKey; + const memoView = view === "trash" ? "trash" : "notebook"; + + if ((memoView === "trash") !== memo.isDeleted) { + return false; + } + + if (memoView === "notebook" && typeof notebookId === "string" && notebookId && memo.notebookId !== notebookId) { + return false; + } + + return memoMatchesFilter(memo, filterMode); +}; + +const hasSearchConstraint = (queryKey: readonly unknown[]) => + typeof queryKey[3] === "string" && queryKey[3].trim().length > 0; + +const sortMemoSummariesForList = (memos: MemoSummary[], queryKey: readonly unknown[]) => { + const sortMode = queryKey[5]; + const sorted = [...memos]; + + if (sortMode === "title-asc") { + return sorted.sort((left, right) => { + const leftTitle = left.title?.trim() || left.excerpt || DEFAULT_MEMO_TITLE; + const rightTitle = right.title?.trim() || right.excerpt || DEFAULT_MEMO_TITLE; + return leftTitle.localeCompare(rightTitle, "zh-CN") || left.id.localeCompare(right.id); + }); + } + + if (sortMode === "created-desc") { + return sorted.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || right.id.localeCompare(left.id)); + } + + return sorted.sort((left, right) => { + if (left.isPinned !== right.isPinned) { + return left.isPinned ? -1 : 1; + } + + return Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id); + }); +}; + +const deduplicateMemoSummaries = (memos: MemoSummary[]) => { + const uniqueMemos = new Map(); + + for (const memo of memos) { + uniqueMemos.set(memo.id, memo); + } + + return Array.from(uniqueMemos.values()); +}; + +const reflowMemoListPages = (current: MemoListQueryData, memos: MemoSummary[], totalCount: number) => { + let offset = 0; + + return { + ...current, + pages: current.pages.map((page) => { + const pageSize = page.memos.length; + const nextPageMemos = memos.slice(offset, offset + pageSize); + offset += pageSize; + + return { + ...page, + memos: nextPageMemos, + totalCount, + }; + }), + }; +}; + +export const updateMemoSummaryInLists = (queryClient: QueryClient, summary: MemoSummary) => { + for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ["memos"] })) { + if (!current) { + continue; + } + + const flatMemos = current.pages.flatMap((page) => page.memos); + const memoExists = flatMemos.some((item) => item.id === summary.id); + const matchesStaticConstraints = memoMatchesStaticListConstraints(summary, queryKey); + const currentTotalCount = current.pages[0]?.totalCount ?? flatMemos.length; + + if (memoExists) { + const nextMemos = matchesStaticConstraints + ? deduplicateMemoSummaries(flatMemos.map((item) => (item.id === summary.id ? { ...item, ...summary } : item))) + : deduplicateMemoSummaries(flatMemos.filter((item) => item.id !== summary.id)); + const totalCount = matchesStaticConstraints ? currentTotalCount : Math.max(0, currentTotalCount - 1); + + queryClient.setQueryData(queryKey, reflowMemoListPages(current, sortMemoSummariesForList(nextMemos, queryKey), totalCount)); + continue; + } + + // A summary does not contain the complete note body, so it cannot reliably + // decide whether a newly seen note matches a server-side full-text search. + if (!matchesStaticConstraints || hasSearchConstraint(queryKey)) { + continue; + } + + const [firstPage, ...restPages] = current.pages; + const nextFirstPage = firstPage + ? { + ...firstPage, + memos: sortMemoSummariesForList(deduplicateMemoSummaries([summary, ...firstPage.memos]), queryKey), + totalCount: firstPage.totalCount + 1, + } + : { memos: [summary], totalCount: 1, nextCursor: null }; + + queryClient.setQueryData(queryKey, { ...current, pages: [nextFirstPage, ...restPages] }); + } +}; diff --git a/package.json b/package.json index c9b8d99d..06436b32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "edgeever", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "module", "workspaces": [ diff --git a/tests/web-memo-list-cache.test.ts b/tests/web-memo-list-cache.test.ts new file mode 100644 index 00000000..2790d9c7 --- /dev/null +++ b/tests/web-memo-list-cache.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import type { MemoSummary } from "@edgeever/shared"; +import { updateMemoSummaryInLists, type MemoListQueryData } from "../apps/web/src/lib/memo-list-cache"; + +const memo = (overrides: Partial = {}): MemoSummary => ({ + id: "memo-apple", + notebookId: "nb-inbox", + title: "Apple developer enrollment", + excerpt: "Apple Developer Program", + tags: [], + isPinned: false, + isArchived: false, + isDeleted: false, + revision: 1, + createdAt: "2026-07-16T12:00:00.000Z", + updatedAt: "2026-07-16T12:00:00.000Z", + deletedAt: null, + ...overrides, +}); + +const queryData = (pages: MemoSummary[][], totalCount = pages.flat().length): MemoListQueryData => ({ + pages: pages.map((memos) => ({ memos, totalCount, nextCursor: null })), + pageParams: pages.map(() => null), +}); + +describe("memo list cache updates", () => { + test("keeps an edited note in an active full-text search until the server refetch resolves membership", () => { + const queryClient = new QueryClient(); + const queryKey = ["memos", "notebook", null, "Apple", "all", "updated-desc"] as const; + queryClient.setQueryData(queryKey, queryData([[memo()]])); + + updateMemoSummaryInLists(queryClient, memo({ title: "Updated title", revision: 2 })); + + const cached = queryClient.getQueryData(queryKey); + expect(cached?.pages[0]?.memos).toHaveLength(1); + expect(cached?.pages[0]?.memos[0]?.title).toBe("Updated title"); + expect(cached?.pages[0]?.memos[0]?.revision).toBe(2); + }); + + test("does not optimistically insert an unseen note into a full-text search", () => { + const queryClient = new QueryClient(); + const queryKey = ["memos", "notebook", null, "Apple", "all", "updated-desc"] as const; + queryClient.setQueryData(queryKey, queryData([[]], 0)); + + updateMemoSummaryInLists(queryClient, memo()); + + expect(queryClient.getQueryData(queryKey)?.pages[0]?.memos).toEqual([]); + }); + + test("deduplicates the same note ID if overlapping infinite-query pages contain it", () => { + const queryClient = new QueryClient(); + const queryKey = ["memos", "notebook", null, "", "all", "updated-desc"] as const; + queryClient.setQueryData(queryKey, queryData([[memo()], [memo({ revision: 2 })]], 1)); + + updateMemoSummaryInLists(queryClient, memo({ revision: 3 })); + + const cached = queryClient.getQueryData(queryKey); + const flattened = cached?.pages.flatMap((page) => page.memos) ?? []; + expect(flattened).toHaveLength(1); + expect(flattened[0]?.revision).toBe(3); + expect(cached?.pages[0]?.totalCount).toBe(1); + }); +});