Files
gitmost/apps/client/src/features/favorite/queries/favorite-query.ts
T
agent_coder 1bcc96685e perf(client): cut background re-renders + duplicate work (#344)
Outside the editor the UI did background work on every tree event, socket
reconnect, and navigation. Tree infra (virtualization/memo/O(N) utils) was
already good — the cost was in the subscriptions and duplicates around it.
Client-only; behavior 1:1.

- Setter-only atom subscriptions → useSetAtom: space-tree-row, use-tree-mutation,
  use-tree-socket no longer subscribe every visible row to the WHOLE treeDataAtom
  value (a tree event re-rendered all ~20-30 rows, bypassing the DocTreeRow memo).
  space-tree-node-menu / mention-list read the tree imperatively (store.get) in
  their handlers only. breadcrumb.tsx uses a selectAtom slice (ancestor chain +
  field equality) instead of the whole-tree subscription.
- Socket handler cleanup (BUG): use-tree-socket + use-query-subscription now
  socket.off() their named handlers on cleanup (were accumulating listeners on
  every reconnect → duplicated invalidations/tree-walks). Mirrors
  use-notification-socket.
- Field-update tree path: invalidateOnUpdatePage does a pointwise patch of the
  cached embed subtrees instead of a blanket invalidatePageTree() (refetch storm);
  structural events keep the blanket invalidate.
- usePageMetaQuery: a content-less select slice for the 13 peripheral subscribers
  that read only title/permissions/id, so they stop re-rendering every ~3s while
  typing / on every collab page.updated (page.tsx keeps the full query for content).
- page.tsx: skeleton + placeholderData keepPreviousData (no blank flash on nav).
- Removed refetchOnMount:true where socket/mutation invalidation already keeps the
  cache fresh (favorite/space/space-watcher/workspace). KEPT it on the 3 queries
  with NO other freshness path (trash-list, created-by, recent-changes) — the
  global default is refetchOnMount:false, so those overrides are load-bearing.
- Small: resize mousemove/up attached only while dragging; per-row emoji-picker
  keydown gated on `opened`; AiChatWindow queries enabled only when the window is
  open.

Gate: client tsc 0, client vitest page+websocket 200 passed (+editor suites),
build ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 04:37:35 +03:00

99 lines
3.2 KiB
TypeScript

import { useMemo } from "react";
import {
useQuery,
useInfiniteQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
addFavorite,
removeFavorite,
getFavorites,
getFavoriteIds,
ToggleFavoriteParams,
} from "../services/favorite-service";
import { FavoriteType } from "../types/favorite.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
return useInfiniteQuery({
queryKey: ["favorites", type, spaceId],
queryFn: ({ pageParam }) =>
getFavorites({ type, spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string> {
const { data } = useQuery({
queryKey: ["favorite-ids", type, spaceId],
queryFn: () => getFavoriteIds(type, spaceId),
});
const items = data?.items;
return useMemo(() => new Set(items ?? []), [items]);
}
function getEntityId(variables: ToggleFavoriteParams): string | undefined {
if (variables.type === "page") return variables.pageId;
if (variables.type === "space") return variables.spaceId;
if (variables.type === "template") return variables.templateId;
return undefined;
}
export function useAddFavoriteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => addFavorite(data),
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
if (!old) return old;
if (old.items.includes(entityId)) return old;
return { ...old, items: [...old.items, entityId] };
},
);
}
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Added to favorites") });
},
});
}
export function useRemoveFavoriteMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, ToggleFavoriteParams>({
mutationFn: (data) => removeFavorite(data),
onSuccess: (_result, variables) => {
const entityId = getEntityId(variables);
if (entityId) {
queryClient.setQueriesData<{ items: string[]; meta: any }>(
{ queryKey: ["favorite-ids", variables.type] },
(old) => {
if (!old) return old;
return { ...old, items: old.items.filter((id) => id !== entityId) };
},
);
}
queryClient.invalidateQueries({
queryKey: ["favorites", variables.type],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Removed from favorites") });
},
});
}