Files
gitmost/apps/client/src/features/workspace/queries/workspace-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

248 lines
6.3 KiB
TypeScript

import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
changeMemberRole,
getInvitationById,
getPendingInvitations,
getWorkspaceMembers,
createInvitation,
resendInvitation,
revokeInvitation,
getWorkspace,
getWorkspacePublicData,
getAppVersion,
deleteWorkspaceMember,
deactivateWorkspaceMember,
activateWorkspaceMember,
} from "@/features/workspace/services/workspace-service";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { notifications } from "@mantine/notifications";
import {
ICreateInvite,
IInvitation,
IPublicWorkspace,
IVersion,
IWorkspace,
} from "@/features/workspace/types/workspace.types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { useTranslation } from "react-i18next";
export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
return useQuery({
queryKey: ["workspace"],
queryFn: () => getWorkspace(),
});
}
export function useWorkspacePublicDataQuery(): UseQueryResult<
IPublicWorkspace,
Error
> {
return useQuery({
queryKey: ["workspace-public"],
queryFn: () => getWorkspacePublicData(),
});
}
export function useWorkspaceMembersQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IUser>, Error> {
return useQuery({
queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params),
placeholderData: keepPreviousData,
});
}
export function useDeleteWorkspaceMemberMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
userId: string;
}
>({
mutationFn: (data) => deleteWorkspaceMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Member deleted successfully" });
queryClient.invalidateQueries({
queryKey: ["workspaceMembers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useDeactivateWorkspaceMemberMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
userId: string;
}
>({
mutationFn: (data) => deactivateWorkspaceMember(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["workspaceMembers"],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Member deactivated") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useActivateWorkspaceMemberMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
userId: string;
}
>({
mutationFn: (data) => activateWorkspaceMember(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["workspaceMembers"],
});
// Notify on success so the action gives visible feedback (issue #128)
notifications.show({ message: t("Member activated") });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useChangeMemberRoleMutation() {
const queryClient = useQueryClient();
return useMutation<any, Error, any>({
mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Member role updated successfully" });
queryClient.refetchQueries({
queryKey: ["workspaceMembers"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useWorkspaceInvitationsQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IInvitation>, Error> {
return useQuery({
queryKey: ["invitations", params],
queryFn: () => getPendingInvitations(params),
placeholderData: keepPreviousData,
});
}
export function useCreateInvitationMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
return useMutation<void, Error, ICreateInvite>({
mutationFn: (data) => createInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Invitation sent") });
queryClient.refetchQueries({
queryKey: ["invitations"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useResendInvitationMutation() {
return useMutation<
void,
Error,
{
invitationId: string;
}
>({
mutationFn: (data) => resendInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation resent" });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useRevokeInvitationMutation() {
const queryClient = useQueryClient();
return useMutation<
void,
Error,
{
invitationId: string;
}
>({
mutationFn: (data) => revokeInvitation(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Invitation revoked" });
queryClient.invalidateQueries({
queryKey: ["invitations"],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useGetInvitationQuery(
invitationId: string,
): UseQueryResult<IInvitation, Error> {
return useQuery({
queryKey: ["invitations", invitationId],
queryFn: () => getInvitationById({ invitationId }),
enabled: !!invitationId,
});
}
export function useAppVersion(
isEnabled: boolean,
): UseQueryResult<IVersion, Error> {
return useQuery({
queryKey: ["version"],
queryFn: () => getAppVersion(),
staleTime: 60 * 60 * 1000, // 1 hr
enabled: isEnabled,
});
}