Files
gitmost/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx
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

333 lines
10 KiB
TypeScript

import { useSetAtom, useStore } from "jotai";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ActionIcon, Menu, rem } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconArrowRight,
IconClockHour4,
IconCopy,
IconDotsVertical,
IconFileExport,
IconLink,
IconStar,
IconStarFilled,
IconTemplate,
IconTrash,
} from "@tabler/icons-react";
import ExportModal from "@/components/common/export-modal";
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import CopyPageModal from "@/features/page/components/copy-page-modal.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { duplicatePage } from "@/features/page/services/page-service.ts";
import { useClipboard } from "@/hooks/use-clipboard";
import { getAppUrl } from "@/lib/config.ts";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import {
useFavoriteIds,
useAddFavoriteMutation,
useRemoveFavoriteMutation,
} from "@/features/favorite/queries/favorite-query";
import {
useToggleTemplateMutation,
useToggleTemporaryMutation,
} from "@/features/page-embed/queries/page-embed-query";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
import { pageToTreeNode } from "@/features/page/tree/utils";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
import classes from "@/features/page/tree/styles/tree.module.css";
export interface NodeMenuProps {
node: SpaceTreeNode;
canEdit: boolean;
}
export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const { t } = useTranslation();
const clipboard = useClipboard({ timeout: 500 });
const { spaceSlug } = useParams();
const { handleDelete } = useTreeMutation(node.spaceId);
// Setter-only: the tree value is read only imperatively inside the duplicate
// handler (via `store` below), never at render, so useSetAtom avoids
// re-rendering every row's NodeMenu on any tree event.
const setData = useSetAtom(treeDataAtom);
const store = useStore();
const emit = useQueryEmit();
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [
movePageModalOpened,
{ open: openMovePageModal, close: closeMoveSpaceModal },
] = useDisclosure(false);
const [
copyPageModalOpened,
{ open: openCopyPageModal, close: closeCopySpaceModal },
] = useDisclosure(false);
const favoriteIds = useFavoriteIds("page", node.spaceId);
const addFavorite = useAddFavoriteMutation();
const removeFavorite = useRemoveFavoriteMutation();
const isFavorited = favoriteIds.has(node.id);
const toggleTemplate = useToggleTemplateMutation();
const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation();
const isTemporary = !!node.temporaryExpiresAt;
const handleToggleTemplate = async () => {
const next = !isTemplate;
try {
await toggleTemplate.mutateAsync({ pageId: node.id, isTemplate: next });
// Reflect the new flag locally so the menu label updates immediately.
setData((prev) =>
treeModel.update(prev, node.id, { isTemplate: next } as any),
);
notifications.show({
message: next
? t("Page marked as template")
: t("Page is no longer a template"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleToggleTemporary = async () => {
const next = !isTemporary;
try {
const res = await toggleTemporary.mutateAsync({
pageId: node.id,
temporary: next,
});
// Reflect the new deadline locally so the icon/menu update immediately.
setData((prev) =>
treeModel.update(prev, node.id, {
temporaryExpiresAt: res.temporaryExpiresAt,
} as any),
);
notifications.show({
message: next
? t("Note will move to trash unless made permanent")
: t("Note is now permanent"),
});
} catch {
// mutation surfaces the error via notifications
}
};
const handleCopyLink = () => {
const pageUrl =
getAppUrl() + buildPageUrl(spaceSlug, node.slugId, node.name);
clipboard.copy(pageUrl);
notifications.show({ message: t("Link copied") });
};
const handleDuplicatePage = async () => {
try {
const duplicatedPage = await duplicatePage({ pageId: node.id });
// figure out parent + insertion index (read the live tree imperatively)
const siblings = treeModel.siblingsOf(store.get(treeDataAtom), node.id);
const parentId = siblings?.parentId ?? null;
const currentIndex = siblings?.index ?? 0;
const newIndex = currentIndex + 1;
// Routed through the canonical mapper so the field copy stays in lockstep
// with buildTree. The server does NOT arm a death timer on duplicate (the
// copy's `temporaryExpiresAt` defaults to null = permanent), so the mapper
// carries that null through and the duplicated node correctly shows no
// clock marker — matching the server without a reload.
const treeNodeData: SpaceTreeNode = pageToTreeNode(duplicatedPage, {
canEdit: true,
});
setData((prev) =>
treeModel.insert(prev, parentId, treeNodeData, newIndex),
);
setTimeout(() => {
emit({
operation: "addTreeNode",
spaceId: node.spaceId,
payload: {
parentId,
index: newIndex,
data: treeNodeData,
},
});
}, 50);
notifications.show({ message: t("Page duplicated successfully") });
} catch (err: any) {
notifications.show({
message: err?.response?.data?.message || "An error occurred",
color: "red",
});
}
};
return (
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon
size={20}
variant="subtle"
color="gray"
className={classes.actionIcon}
aria-label={t("Page menu for {{name}}", { name: node.name || t("Untitled") })}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<IconDotsVertical
style={{ width: rem(20), height: rem(20) }}
stroke={2}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLink size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyLink();
}}
>
{t("Copy link")}
</Menu.Item>
<Menu.Item
leftSection={
isFavorited ? <IconStarFilled size={16} /> : <IconStar size={16} />
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isFavorited) {
removeFavorite.mutate({ type: "page", pageId: node.id });
} else {
addFavorite.mutate({ type: "page", pageId: node.id });
}
}}
>
{isFavorited ? t("Remove from favorites") : t("Add to favorites")}
</Menu.Item>
<Menu.Item
leftSection={<IconFileExport size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openExportModal();
}}
>
{t("Export")}
</Menu.Item>
{canEdit && (
<>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDuplicatePage();
}}
>
{t("Duplicate")}
</Menu.Item>
<Menu.Item
leftSection={<IconArrowRight size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openMovePageModal();
}}
>
{t("Move to space")}
</Menu.Item>
<Menu.Item
leftSection={<IconCopy size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openCopyPageModal();
}}
>
{t("Copy to space")}
</Menu.Item>
<Menu.Item
leftSection={<IconTemplate size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleTemplate();
}}
>
{isTemplate ? t("Unset as template") : t("Make template")}
</Menu.Item>
<Menu.Item
leftSection={<IconClockHour4 size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggleTemporary();
}}
>
{isTemporary ? t("Make permanent") : t("Make temporary")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
c="red"
leftSection={<IconTrash size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDelete(node.id);
}}
>
{t("Move to trash")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
<MovePageModal
pageId={node.id}
slugId={node.slugId}
currentSpaceSlug={spaceSlug}
onClose={closeMoveSpaceModal}
open={movePageModalOpened}
/>
<CopyPageModal
pageId={node.id}
currentSpaceSlug={spaceSlug}
onClose={closeCopySpaceModal}
open={copyPageModalOpened}
/>
<ExportModal
type="page"
id={node.id}
open={exportOpened}
onClose={closeExportModal}
/>
</>
);
}