fix: prevent search edit note duplication (#36)
This commit is contained in:
1 parent
6c5a4980f7
commit
01c6174ffd
5 files changed
+232
-133
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
]);
|
||||
}}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "edgeever",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user