fcbe840c74
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>
82 lines
3.0 KiB
TypeScript
82 lines
3.0 KiB
TypeScript
import { useEffect } from "react";
|
|
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
|
import { useAtom, useSetAtom } from "jotai";
|
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
import { WebSocketEvent } from "@/features/websocket/types";
|
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
|
import {
|
|
applyUpdateOne,
|
|
applyAddTreeNode,
|
|
applyMoveTreeNode,
|
|
applyDeleteTreeNode,
|
|
} from "@/features/websocket/tree-socket-reducers.ts";
|
|
import localEmitter from "@/lib/local-emitter.ts";
|
|
|
|
export const useTreeSocket = () => {
|
|
const [socket] = useAtom(socketAtom);
|
|
// Setter-only: this hook writes the tree from socket events but never reads it
|
|
// reactively, so useSetAtom avoids re-rendering UserProvider (its host) on
|
|
// every tree event.
|
|
const setTreeData = useSetAtom(treeDataAtom);
|
|
const queryClient = useQueryClient();
|
|
|
|
useEffect(() => {
|
|
const updateNodeName = (event) => {
|
|
if (event.payload?.title === undefined) return;
|
|
setTreeData((prev) => {
|
|
if (!treeModel.find(prev, event?.id)) return prev;
|
|
return treeModel.update(prev, event.id, {
|
|
name: event.payload.title,
|
|
} as Partial<SpaceTreeNode>);
|
|
});
|
|
};
|
|
|
|
localEmitter.on("message", updateNodeName);
|
|
return () => {
|
|
localEmitter.off("message", updateNodeName);
|
|
};
|
|
}, []);
|
|
|
|
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 tree walks after each reconnect.
|
|
const handleMessage = (event: WebSocketEvent) => {
|
|
switch (event.operation) {
|
|
case "updateOne":
|
|
if (event.entity[0] === "pages") {
|
|
setTreeData((prev) => applyUpdateOne(prev, event));
|
|
}
|
|
break;
|
|
case "addTreeNode":
|
|
setTreeData((prev) => applyAddTreeNode(prev, event.payload));
|
|
break;
|
|
case "moveTreeNode":
|
|
setTreeData((prev) => applyMoveTreeNode(prev, event.payload));
|
|
break;
|
|
case "deleteTreeNode":
|
|
// The `invalidateQueries` side effect stays in the hook; the tree
|
|
// transform (`applyDeleteTreeNode`) is pure. Only invalidate when the
|
|
// node is actually in the tree (mirrors the pure reducer's early-out).
|
|
setTreeData((prev) => {
|
|
if (treeModel.find(prev, event.payload.node.id)) {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["pages", event.payload.node.slugId].filter(Boolean),
|
|
});
|
|
}
|
|
return applyDeleteTreeNode(prev, event.payload);
|
|
});
|
|
break;
|
|
}
|
|
};
|
|
|
|
socket.on("message", handleMessage);
|
|
return () => {
|
|
socket.off("message", handleMessage);
|
|
};
|
|
}, [socket, queryClient, setTreeData]);
|
|
};
|