perf(web): deduplicate explore data requests

Share canonical user and memo query caches across creator, reaction, comment, mention, and relation lookups. Reuse list data for relation snippets and cover overlapping queries with regression tests.
This commit is contained in:
johnnyjoygh
2026-07-18 23:25:59 +08:00
parent 88c6ee8ebc
commit 6c1055f483
8 changed files with 188 additions and 65 deletions
@@ -141,7 +141,15 @@ const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => {
const { data: creator } = useUser(memo.creator, { enabled: !!memo.creator });
const headings = useMemo(() => extractHeadings(memo.content), [memo.content]);
const { referencing, referenced } = useMemo(() => getRelationBuckets(memo.relations, memo.name), [memo.relations, memo.name]);
const resolvedMemos = useResolvedRelationMemos(memo.relations);
const relationMemoNames = useMemo(
() =>
[
...referencing.map((relation) => getRelationMemo(relation, "referencing")),
...referenced.map((relation) => getRelationMemo(relation, "referenced")),
].flatMap((relatedMemo) => (relatedMemo?.name && !relatedMemo.snippet ? [relatedMemo.name] : [])),
[referenced, referencing],
);
const resolvedMemos = useResolvedRelationMemos(relationMemoNames);
const createTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
const updateTime = memo.updateTime ? timestampDate(memo.updateTime) : undefined;
@@ -40,7 +40,14 @@ const RelationItemCard: FC<{
const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelationsChange, parentPage, memoName }) => {
const referenceRelations = useMemo(() => getEditorReferenceRelations(relations, memoName), [relations, memoName]);
const resolvedMemos = useResolvedRelationMemos(referenceRelations);
const relatedMemoNames = useMemo(
() =>
referenceRelations.flatMap((relation) =>
relation.relatedMemo?.name && !relation.relatedMemo.snippet ? [relation.relatedMemo.name] : [],
),
[referenceRelations],
);
const resolvedMemos = useResolvedRelationMemos(relatedMemoNames);
const handleDeleteRelation = (memoName: string) => {
if (onRelationsChange) {
@@ -17,7 +17,6 @@ interface RelationListViewProps {
function RelationListView({ relations, currentMemoName, parentPage, className }: RelationListViewProps) {
const t = useTranslate();
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
const resolvedMemos = useResolvedRelationMemos(relations);
const { referencing: referencingRelations, referenced: referencedRelations } = useMemo(
() => getRelationBuckets(relations, currentMemoName),
@@ -33,6 +32,15 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
const isReferencing = direction === "referencing";
const icon = isReferencing ? LinkIcon : MilestoneIcon;
const activeRelations = isReferencing ? referencingRelations : referencedRelations;
const activeMemoNames = useMemo(
() =>
activeRelations.flatMap((relation) => {
const memo = getRelationMemo(relation, direction);
return memo?.name && !memo.snippet ? [memo.name] : [];
}),
[activeRelations, direction],
);
const resolvedMemos = useResolvedRelationMemos(activeMemoNames);
return (
<MetadataSection
@@ -1,25 +1,16 @@
import { create } from "@bufbuild/protobuf";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { memoServiceClient } from "@/connect";
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
import { findMemoInCollectionQueries, memoDetailQueryOptions } from "@/hooks/useMemoQueries";
import { MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
export const useResolvedRelationMemos = (relations: MemoRelation[]) => {
export const useResolvedRelationMemos = (memoNames: string[]) => {
const queryClient = useQueryClient();
const [resolvedMemos, setResolvedMemos] = useState<Record<string, MemoRelation_Memo>>({});
const missingMemoNames = useMemo(() => {
const names = new Set<string>();
for (const relation of relations) {
for (const memo of [relation.memo, relation.relatedMemo]) {
if (memo?.name && !memo.snippet && !resolvedMemos[memo.name]) {
names.add(memo.name);
}
}
}
return [...names];
}, [relations, resolvedMemos]);
return Array.from(new Set(memoNames)).filter((name) => name && !resolvedMemos[name]);
}, [memoNames, resolvedMemos]);
useEffect(() => {
if (missingMemoNames.length === 0) {
@@ -32,7 +23,7 @@ export const useResolvedRelationMemos = (relations: MemoRelation[]) => {
try {
const memos = await Promise.all(
missingMemoNames.map(async (name) => {
const memo = await memoServiceClient.getMemo({ name });
const memo = findMemoInCollectionQueries(queryClient, name) ?? (await queryClient.fetchQuery(memoDetailQueryOptions(name)));
return create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet });
}),
);
@@ -56,7 +47,7 @@ export const useResolvedRelationMemos = (relations: MemoRelation[]) => {
return () => {
cancelled = true;
};
}, [missingMemoNames]);
}, [missingMemoNames, queryClient]);
return resolvedMemos;
};
@@ -1,16 +1,13 @@
import { useQueryClient } from "@tanstack/react-query";
import { ArrowUpIcon, LoaderCircleIcon } from "lucide-react";
import { type ReactElement, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useNewMemo } from "@/contexts/NewMemoContext";
import { useView } from "@/contexts/ViewContext";
import { useDelayedFlag } from "@/hooks/useDelayedFlag";
import { useInfiniteMemos } from "@/hooks/useMemoQueries";
import { hoistMemoToFront } from "@/hooks/useMemoSorting";
import { userKeys } from "@/hooks/useUserQueries";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE, LOADING_INDICATOR_DELAY_MS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { State } from "@/types/proto/api/v1/common_pb";
@@ -101,7 +98,6 @@ function useAutoFetchWhenNotScrollable({
const PagedMemoList = (props: Props) => {
const t = useTranslate();
const queryClient = useQueryClient();
const { filters } = useMemoFilterContext();
const { maxColumns, compactMode } = useView();
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
@@ -154,26 +150,6 @@ const PagedMemoList = (props: Props) => {
return hoistMemoToFront(sorted, newMemoName);
}, [memos, props.listSort, newMemoName]);
// Prefetch creators when new data arrives to improve performance
useEffect(() => {
if (!data?.pages || !props.showCreator) return;
const lastPage = data.pages[data.pages.length - 1];
if (!lastPage?.memos) return;
const uniqueCreators = Array.from(new Set(lastPage.memos.map((memo) => memo.creator)));
for (const creator of uniqueCreators) {
void queryClient.prefetchQuery({
queryKey: userKeys.detail(creator),
queryFn: async () => {
const user = await userServiceClient.getUser({ name: creator });
return user;
},
staleTime: 1000 * 60 * 5,
});
}
}, [data?.pages, props.showCreator, queryClient]);
// Auto-fetch hook: fetches more content when page isn't scrollable
useAutoFetchWhenNotScrollable({
hasNextPage,
+19 -10
View File
@@ -1,7 +1,14 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import type { InfiniteData } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type InfiniteData,
type QueryClient,
queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { memoServiceClient } from "@/connect";
import { userKeys } from "@/hooks/useUserQueries";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/lib/constants";
@@ -19,6 +26,13 @@ export const memoKeys = {
linkMetadata: (url: string) => [...memoKeys.all, "linkMetadata", url] as const,
};
export const memoDetailQueryOptions = (name: string) =>
queryOptions({
queryKey: memoKeys.detail(name),
queryFn: () => memoServiceClient.getMemo({ name }),
staleTime: 1000 * 10,
});
type MemoPatch = Partial<Memo> & Pick<Memo, "name">;
type MemoCollectionQueryData = ListMemosResponse | InfiniteData<ListMemosResponse>;
@@ -94,7 +108,7 @@ function findMemoInQueryData(data: unknown, name: string): Memo | undefined {
return undefined;
}
function findMemoInCollectionQueries(queryClient: ReturnType<typeof useQueryClient>, name: string): Memo | undefined {
export function findMemoInCollectionQueries(queryClient: QueryClient, name: string): Memo | undefined {
for (const [, data] of queryClient.getQueriesData<unknown>({ queryKey: memoKeys.all })) {
const memo = findMemoInQueryData(data, name);
if (memo) {
@@ -105,7 +119,7 @@ function findMemoInCollectionQueries(queryClient: ReturnType<typeof useQueryClie
return undefined;
}
function patchMemoInCollectionQueries(queryClient: ReturnType<typeof useQueryClient>, update: MemoPatch) {
function patchMemoInCollectionQueries(queryClient: QueryClient, update: MemoPatch) {
queryClient.setQueriesData<MemoCollectionQueryData>({ queryKey: memoKeys.all }, (data) => patchMemoListQueryData(data, update));
}
@@ -141,13 +155,8 @@ export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}, option
export function useMemo(name: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: memoKeys.detail(name),
queryFn: async () => {
const memo = await memoServiceClient.getMemo({ name });
return memo;
},
...memoDetailQueryOptions(name),
enabled: options?.enabled ?? true,
staleTime: 1000 * 10, // 10 seconds - reduced to prevent stale data in collaborative editing
});
}
+20 -11
View File
@@ -1,6 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { shortcutServiceClient, userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { buildUserSettingName } from "@/lib/resource-names";
@@ -16,6 +16,7 @@ import {
} from "@/types/proto/api/v1/user_service_pb";
const BATCH_GET_USERS_LIMIT = 100;
const USER_PROFILE_STALE_TIME = 1000 * 60 * 5;
type ListAllUserStatsQuery = Pick<ListAllUserStatsRequest, "state" | "filter">;
// Query keys factory
@@ -33,15 +34,17 @@ export const userKeys = {
byUsernames: (usernames: string[]) => [...userKeys.all, "byUsernames", ...[...usernames].sort()] as const,
};
const userDetailQueryOptions = (name: string) =>
queryOptions({
queryKey: userKeys.detail(name),
queryFn: () => userServiceClient.getUser({ name }),
staleTime: USER_PROFILE_STALE_TIME,
});
export function useUser(name: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: userKeys.detail(name),
queryFn: async () => {
const user = await userServiceClient.getUser({ name });
return user;
},
...userDetailQueryOptions(name),
enabled: options?.enabled ?? true,
staleTime: 1000 * 60 * 5, // 5 minutes - user profiles don't change often
});
}
@@ -255,6 +258,7 @@ export function useUpdateUserGeneralSetting(currentUserName?: string) {
// Hook to fetch multiple users by names (returns Map<name, User>)
export function useUsersByNames(names: string[]) {
const queryClient = useQueryClient();
const enabled = names.length > 0;
const uniqueNames = Array.from(new Set(names));
@@ -264,7 +268,7 @@ export function useUsersByNames(names: string[]) {
const users = await Promise.all(
uniqueNames.map(async (name) => {
try {
const user = await userServiceClient.getUser({ name });
const user = await queryClient.fetchQuery(userDetailQueryOptions(name));
return { name, user };
} catch {
return { name, user: undefined };
@@ -279,12 +283,13 @@ export function useUsersByNames(names: string[]) {
return userMap;
},
enabled,
staleTime: 1000 * 60 * 5, // 5 minutes - user profiles don't change often
staleTime: USER_PROFILE_STALE_TIME,
});
}
// Hook to fetch multiple users by usernames (returns Map<username, User>)
export function useUsersByUsernames(usernames: string[], options?: { enabled?: boolean }) {
const queryClient = useQueryClient();
const enabled = (options?.enabled ?? true) && usernames.length > 0;
const uniqueUsernames = Array.from(new Set(usernames));
@@ -297,7 +302,11 @@ export function useUsersByUsernames(usernames: string[], options?: { enabled?: b
}
const responses = await Promise.all(batches.map((batch) => userServiceClient.batchGetUsers({ usernames: batch })));
const usersByUsername = new Map(responses.flatMap((response) => response.users).map((user) => [user.username, user] as const));
const users = responses.flatMap((response) => response.users);
const usersByUsername = new Map(users.map((user) => [user.username, user] as const));
for (const user of users) {
queryClient.setQueryData(userKeys.detail(user.name), user);
}
const userMap = new Map<string, User | undefined>();
for (const username of uniqueUsernames) {
@@ -306,6 +315,6 @@ export function useUsersByUsernames(usernames: string[], options?: { enabled?: b
return userMap;
},
enabled,
staleTime: 1000 * 60 * 5,
staleTime: USER_PROFILE_STALE_TIME,
});
}
+115
View File
@@ -0,0 +1,115 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { PropsWithChildren } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
import { memoKeys } from "@/hooks/useMemoQueries";
import { useUser, userKeys, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import type { User } from "@/types/proto/api/v1/user_service_pb";
const clients = vi.hoisted(() => ({
batchGetUsers: vi.fn(),
getMemo: vi.fn(),
getUser: vi.fn(),
}));
vi.mock("@/connect", () => ({
memoServiceClient: {
getMemo: clients.getMemo,
},
shortcutServiceClient: {},
userServiceClient: {
batchGetUsers: clients.batchGetUsers,
getUser: clients.getUser,
},
}));
const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (queryClient: QueryClient) =>
function QueryWrapper({ children }: PropsWithChildren) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
describe("query deduplication", () => {
beforeEach(() => {
clients.batchGetUsers.mockReset();
clients.getMemo.mockReset();
clients.getUser.mockReset();
});
it("fetches each user name once across individual and overlapping group queries", async () => {
clients.getUser.mockImplementation(async ({ name }: { name: string }) => ({ name, username: name }) as User);
const queryClient = createQueryClient();
const { result } = renderHook(
() => ({
individual: useUser("users/1"),
firstGroup: useUsersByNames(["users/1", "users/2"]),
secondGroup: useUsersByNames(["users/2", "users/3"]),
}),
{ wrapper: createWrapper(queryClient) },
);
await waitFor(() => {
expect(result.current.individual.isSuccess).toBe(true);
expect(result.current.firstGroup.isSuccess).toBe(true);
expect(result.current.secondGroup.isSuccess).toBe(true);
});
expect(clients.getUser.mock.calls.map(([request]) => request.name).sort()).toEqual(["users/1", "users/2", "users/3"]);
});
it("seeds user detail queries from a username batch response", async () => {
const alice = { name: "users/1", username: "alice" } as User;
clients.batchGetUsers.mockResolvedValue({ users: [alice] });
const queryClient = createQueryClient();
const wrapper = createWrapper(queryClient);
const batch = renderHook(() => useUsersByUsernames(["alice"]), { wrapper });
await waitFor(() => expect(batch.result.current.isSuccess).toBe(true));
expect(queryClient.getQueryData(userKeys.detail(alice.name))).toBe(alice);
const detail = renderHook(() => useUser(alice.name), { wrapper });
await waitFor(() => expect(detail.result.current.isSuccess).toBe(true));
expect(clients.getUser).not.toHaveBeenCalled();
});
it("reuses memo list data and canonical detail queries when resolving relations", async () => {
const cachedMemo = { name: "memos/cached", snippet: "Already in the list" } as Memo;
const missingMemo = { name: "memos/missing", snippet: "Fetched once" } as Memo;
clients.getMemo.mockResolvedValue(missingMemo);
const queryClient = createQueryClient();
queryClient.setQueryData(memoKeys.list({}), {
pages: [{ memos: [cachedMemo], nextPageToken: "" }],
pageParams: [""],
});
const { result } = renderHook(
() => ({
first: useResolvedRelationMemos([cachedMemo.name, missingMemo.name]),
second: useResolvedRelationMemos([missingMemo.name]),
}),
{ wrapper: createWrapper(queryClient) },
);
await waitFor(() => {
expect(result.current.first[cachedMemo.name]?.snippet).toBe(cachedMemo.snippet);
expect(result.current.first[missingMemo.name]?.snippet).toBe(missingMemo.snippet);
expect(result.current.second[missingMemo.name]?.snippet).toBe(missingMemo.snippet);
});
expect(clients.getMemo).toHaveBeenCalledTimes(1);
expect(clients.getMemo).toHaveBeenCalledWith({ name: missingMemo.name });
});
});