fix: prevent search edit note duplication (#36)

This commit is contained in:
tianma-if committed 2026-07-17 00:27:13 +08:00
1 parent 6c5a4980f7
commit 01c6174ffd
5 files changed
+232 -133

No files matched your search

+16 -4
View File
@@ -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;
}
+12 -128
View File
@@ -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<MemoListQueryData>({ 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<string>) => {
const summaries = new Map<string, MemoSummary>();
@@ -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" }),
]);
}}
+139
View File
@@ -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<string, MemoSummary>();
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<MemoListQueryData>({ 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] });
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "edgeever",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"type": "module",
"workspaces": [
+64
View File
@@ -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> = {}): 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<MemoListQueryData>(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<MemoListQueryData>(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<MemoListQueryData>(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);
});
});