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>
This commit is contained in:
agent_coder
2026-07-05 00:34:51 +03:00
committed by agent_coder
parent ee33a293b9
commit 1bcc96685e
29 changed files with 303 additions and 80 deletions
@@ -24,7 +24,7 @@ import classes from "./link.module.css";
import { useTranslation } from "react-i18next";
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
import { extractPageSlugId } from "@/lib";
@@ -83,7 +83,7 @@ export default function LinkView(props: MarkViewProps) {
const isPopoverVisible = popoverState !== "closed";
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
const { data: linkedPage } = usePageQuery({
const { data: linkedPage } = usePageMetaQuery({
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
});
@@ -25,7 +25,7 @@ import { IconFileDescription, IconPlus } from "@tabler/icons-react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useParams } from "react-router-dom";
import { v7 as uuid7 } from "uuid";
import { useAtom } from "jotai";
import { useAtom, useSetAtom, useStore } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
MentionListProps,
@@ -34,7 +34,7 @@ import {
import { IPage } from "@/features/page/types/page.types";
import {
useCreatePageMutation,
usePageQuery,
usePageMetaQuery,
} from "@/features/page/queries/page-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -50,12 +50,16 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const [countAnnouncement, setCountAnnouncement] = useState("");
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
const { pageSlug, spaceSlug } = useParams();
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
const { data: space } = useSpaceQuery(spaceSlug);
const [currentUser] = useAtom(currentUserAtom);
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
const { t } = useTranslation();
const [data, setData] = useAtom(treeDataAtom);
// Setter-only: the tree value is read only imperatively inside createPage
// (via `store` below), never at render, so useSetAtom avoids re-rendering the
// mention popup on any tree event.
const setData = useSetAtom(treeDataAtom);
const store = useStore();
const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
@@ -272,9 +276,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
children: [],
};
const lastIndex = data.length;
// Read the live tree imperatively at call time.
const currentTree = store.get(treeDataAtom);
const lastIndex = currentTree.length;
setData(treeModel.insert(data, parentId, newNode, lastIndex));
setData(treeModel.insert(currentTree, parentId, newNode, lastIndex));
props.command({
id: uuid7(),
@@ -2,7 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import {
buildPageUrl,
@@ -36,7 +36,7 @@ export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
} = usePageMetaQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const { data: sharedPage } = useSharePageQuery({
pageId: isPageMention && isShareRoute ? slugId : undefined,