b71386b78d
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>
178 lines
5.8 KiB
TypeScript
178 lines
5.8 KiB
TypeScript
import React from "react";
|
|
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
|
import { useAtom } from "jotai";
|
|
import { InfiniteData, useQueryClient } from "@tanstack/react-query";
|
|
import { WebSocketEvent } from "@/features/websocket/types";
|
|
import { IPage } from "../page/types/page.types";
|
|
import { IPagination } from "@/lib/types";
|
|
import {
|
|
invalidateOnCreatePage,
|
|
invalidateOnDeletePage,
|
|
updateCacheOnMovePage,
|
|
invalidateOnUpdatePage,
|
|
} from "../page/queries/page-query";
|
|
import { RQ_KEY } from "../comment/queries/comment-query";
|
|
import { IComment } from "@/features/comment/types/comment.types";
|
|
|
|
export const useQuerySubscription = () => {
|
|
const queryClient = useQueryClient();
|
|
const [socket] = useAtom(socketAtom);
|
|
|
|
React.useEffect(() => {
|
|
if (!socket) return;
|
|
// Named handler + off() cleanup (mirrors use-notification-socket). Without
|
|
// cleanup, every socket recreation / effect re-run stacked another listener,
|
|
// so a single broadcast fired duplicated invalidateQueries / setQueryData.
|
|
const handleMessage = (event) => {
|
|
const data: WebSocketEvent = event;
|
|
|
|
let entity = null;
|
|
let queryKeyId = null;
|
|
|
|
switch (data.operation) {
|
|
case "invalidate":
|
|
queryClient.invalidateQueries({
|
|
queryKey: [...data.entity, data.id].filter(Boolean),
|
|
});
|
|
break;
|
|
case "commentCreated": {
|
|
const createCache = queryClient.getQueryData(
|
|
RQ_KEY(data.pageId),
|
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
|
|
|
if (createCache && createCache.pages.length > 0) {
|
|
const alreadyExists = createCache.pages.some((page) =>
|
|
page.items.some((c) => c.id === data.comment.id),
|
|
);
|
|
if (alreadyExists) break;
|
|
|
|
const lastIdx = createCache.pages.length - 1;
|
|
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
|
...createCache,
|
|
pages: createCache.pages.map((page, i) =>
|
|
i === lastIdx
|
|
? { ...page, items: [...page.items, data.comment] }
|
|
: page,
|
|
),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "commentUpdated":
|
|
case "commentResolved": {
|
|
const updateCache = queryClient.getQueryData(
|
|
RQ_KEY(data.pageId),
|
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
|
|
|
if (updateCache) {
|
|
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
|
...updateCache,
|
|
pages: updateCache.pages.map((page) => ({
|
|
...page,
|
|
items: page.items.map((comment) =>
|
|
comment.id === data.comment.id ? data.comment : comment,
|
|
),
|
|
})),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "commentDeleted": {
|
|
const deleteCache = queryClient.getQueryData(
|
|
RQ_KEY(data.pageId),
|
|
) as InfiniteData<IPagination<IComment>> | undefined;
|
|
|
|
if (deleteCache) {
|
|
queryClient.setQueryData(RQ_KEY(data.pageId), {
|
|
...deleteCache,
|
|
pages: deleteCache.pages.map((page) => ({
|
|
...page,
|
|
items: page.items.filter(
|
|
(comment) => comment.id !== data.commentId,
|
|
),
|
|
})),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "addTreeNode":
|
|
invalidateOnCreatePage(data.payload.data);
|
|
break;
|
|
case "moveTreeNode":
|
|
updateCacheOnMovePage(
|
|
data.spaceId,
|
|
data.payload.id,
|
|
data.payload.oldParentId,
|
|
data.payload.parentId,
|
|
data.payload.pageData,
|
|
);
|
|
break;
|
|
case "deleteTreeNode":
|
|
invalidateOnDeletePage(data.payload.node.id);
|
|
break;
|
|
case "updateOne":
|
|
entity = data.entity[0];
|
|
if (entity === "pages") {
|
|
// we have to do this because the usePageQuery cache key is the slugId.
|
|
queryKeyId = data.payload.slugId;
|
|
} else {
|
|
queryKeyId = data.id;
|
|
}
|
|
|
|
// only update if data was already in cache
|
|
if (queryClient.getQueryData([...data.entity, queryKeyId])) {
|
|
queryClient.setQueryData([...data.entity, queryKeyId], {
|
|
...queryClient.getQueryData([...data.entity, queryKeyId]),
|
|
...data.payload,
|
|
});
|
|
}
|
|
|
|
if (entity === "pages") {
|
|
invalidateOnUpdatePage(
|
|
data.spaceId,
|
|
data.payload.parentPageId,
|
|
data.id,
|
|
data.payload.title,
|
|
data.payload.icon,
|
|
);
|
|
}
|
|
|
|
/*
|
|
queryClient.setQueriesData(
|
|
{ queryKey: [data.entity, data.id] },
|
|
(oldData: any) => {
|
|
const update = (entity: Record<string, unknown>) =>
|
|
entity.id === data.id ? { ...entity, ...data.payload } : entity;
|
|
return Array.isArray(oldData)
|
|
? oldData.map(update)
|
|
: update(oldData as Record<string, unknown>);
|
|
},
|
|
);
|
|
*/
|
|
break;
|
|
case "refetchRootTreeNodeEvent": {
|
|
const spaceId = data.spaceId;
|
|
queryClient.refetchQueries({
|
|
queryKey: ["root-sidebar-pages", spaceId],
|
|
});
|
|
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["recent-changes", spaceId],
|
|
});
|
|
break;
|
|
}
|
|
case "verificationUpdated":
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["page-verification-info", data.pageId],
|
|
});
|
|
break;
|
|
}
|
|
};
|
|
|
|
socket.on("message", handleMessage);
|
|
return () => {
|
|
socket.off("message", handleMessage);
|
|
};
|
|
}, [queryClient, socket]);
|
|
};
|