diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index 0eccb723c..e7a2e06d1 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -33,6 +33,10 @@ import { import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; +import { + spaceRoots, + updateSpaceRoots, +} from "@/features/page/tree/utils/utils.ts"; 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"; @@ -77,7 +81,10 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { const duplicatedPage = await duplicatePage({ pageId: node.id }); // figure out parent + insertion index - const siblings = treeModel.siblingsOf(data, node.id); + const siblings = treeModel.siblingsOf( + spaceRoots(data, node.spaceId), + node.id, + ); const parentId = siblings?.parentId ?? null; const currentIndex = siblings?.index ?? 0; const newIndex = currentIndex + 1; @@ -96,7 +103,9 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { }; setData((prev) => - treeModel.insert(prev, parentId, treeNodeData, newIndex), + updateSpaceRoots(prev, node.spaceId, (roots) => + treeModel.insert(roots, parentId, treeNodeData, newIndex), + ), ); setTimeout(() => { diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 3b85d8ea0..c8a8e93f6 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -16,6 +16,8 @@ import { buildTree, buildTreeWithChildren, mergeRootTrees, + spaceRoots, + updateSpaceRoots, } from "@/features/page/tree/utils/utils.ts"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { getPageTitle } from "@/features/page/page.utils"; @@ -66,18 +68,14 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { const allItems = pagesData.pages.flatMap((page) => page.items); const treeData = buildTree(allItems); - setData((prev) => { - // Keep nodes belonging to other spaces — filteredData filters by spaceId - // for rendering, so accumulating is safe. Preserves lazy-loaded children - // and open-state when the user returns to a previously-visited space. - const otherSpaces = prev.filter((n) => n?.spaceId !== spaceId); - const currentSpace = prev.filter((n) => n?.spaceId === spaceId); - const refreshed = - currentSpace.length > 0 - ? mergeRootTrees(currentSpace, treeData) - : treeData; - return [...otherSpaces, ...refreshed]; - }); + // Keep nodes belonging to other spaces — filteredData filters by spaceId + // for rendering, so accumulating is safe. Preserves lazy-loaded children + // and open-state when the user returns to a previously-visited space. + setData((prev) => + updateSpaceRoots(prev, spaceId, (roots) => + roots.length > 0 ? mergeRootTrees(roots, treeData) : treeData, + ), + ); setIsDataLoaded(true); }, [pagesData, hasNextPage, spaceId]); @@ -183,7 +181,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) { ); const filteredData = useMemo( - () => data.filter((node) => node?.spaceId === spaceId), + () => spaceRoots(data, spaceId), [data, spaceId], ); diff --git a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts index acdcb0190..5cb50a481 100644 --- a/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts +++ b/apps/client/src/features/page/tree/hooks/use-tree-mutation.ts @@ -7,6 +7,10 @@ import { useNavigate, useParams } from "react-router-dom"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts"; import { treeModel } from "@/features/page/tree/model/tree-model"; import type { DropOp } from "@/features/page/tree/model/tree-model.types"; +import { + spaceRoots, + updateSpaceRoots, +} from "@/features/page/tree/utils/utils.ts"; import { dropOpToMovePayload } from "./drop-op-to-move-payload"; import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { IPage } from "@/features/page/types/page.types.ts"; @@ -45,7 +49,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation { const handleMove = useCallback( async (sourceId: string, op: DropOp) => { - const before = store.get(treeDataAtom); + const before = spaceRoots(store.get(treeDataAtom), spaceId); const { tree: after, result } = treeModel.move(before, sourceId, op); if (after === before) return; @@ -80,12 +84,12 @@ export function useTreeMutation(spaceId: string): UseTreeMutation { } as Partial); } - setData(optimistic); + setData((prev) => updateSpaceRoots(prev, spaceId, () => optimistic)); try { await movePageMutation.mutateAsync(payload); } catch { - setData(before); + setData((prev) => updateSpaceRoots(prev, spaceId, () => before)); notifications.show({ message: t("Failed to move page"), color: "red", @@ -157,7 +161,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation { // tree (e.g. lazy-load children on expand) immediately before calling // handleCreate hit a stale closure and compute lastIndex against the // pre-load tree, requiring a setTimeout-based wait at the call site. - const current = store.get(treeDataAtom); + const current = spaceRoots(store.get(treeDataAtom), spaceId); let lastIndex: number; if (parentId === null) { lastIndex = current.length; @@ -166,7 +170,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation { lastIndex = parent?.children?.length ?? 0; } - setData((prev) => treeModel.insert(prev, parentId, newNode, lastIndex)); + setData((prev) => + updateSpaceRoots(prev, spaceId, (roots) => + treeModel.insert(roots, parentId, newNode, lastIndex), + ), + ); setTimeout(() => { emit({ diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index ac9c70565..dde817b01 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -220,3 +220,21 @@ export function mergeRootTrees( return sortPositionKeys(merged); } + +export function spaceRoots( + tree: SpaceTreeNode[], + spaceId: string, +): SpaceTreeNode[] { + return tree.filter((node) => node?.spaceId === spaceId); +} + +export function updateSpaceRoots( + tree: SpaceTreeNode[], + spaceId: string, + update: (roots: SpaceTreeNode[]) => SpaceTreeNode[], +): SpaceTreeNode[] { + const roots = spaceRoots(tree, spaceId); + const next = update(roots); + if (next === roots) return tree; + return [...tree.filter((node) => node?.spaceId !== spaceId), ...next]; +} diff --git a/apps/client/src/features/websocket/use-tree-socket.ts b/apps/client/src/features/websocket/use-tree-socket.ts index e1c19f29f..d52ea0b5a 100644 --- a/apps/client/src/features/websocket/use-tree-socket.ts +++ b/apps/client/src/features/websocket/use-tree-socket.ts @@ -6,6 +6,7 @@ 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 { updateSpaceRoots } from "@/features/page/tree/utils/utils.ts"; import localEmitter from "@/lib/local-emitter.ts"; export const useTreeSocket = () => { @@ -61,65 +62,69 @@ export const useTreeSocket = () => { setTreeData((prev) => { if (treeModel.find(prev, event.payload.data.id)) return prev; const newParentId = event.payload.parentId as string | null; - let next = treeModel.insert( - prev, - newParentId, - event.payload.data, - event.payload.index, - ); - // Mirror the emitter: flip new parent's hasChildren to true so - // the chevron renders on the receiver. - if (newParentId) { - next = treeModel.update(next, newParentId, { - hasChildren: true, - } as Partial); - } - return next; + return updateSpaceRoots(prev, event.spaceId, (roots) => { + let next = treeModel.insert( + roots, + newParentId, + event.payload.data, + event.payload.index, + ); + // Mirror the emitter: flip new parent's hasChildren to true so + // the chevron renders on the receiver. + if (newParentId) { + next = treeModel.update(next, newParentId, { + hasChildren: true, + } as Partial); + } + return next; + }); }); break; case "moveTreeNode": - setTreeData((prev) => { - const sourceBefore = treeModel.find(prev, event.payload.id); - if (!sourceBefore) return prev; - const oldParentId = - (sourceBefore as SpaceTreeNode).parentPageId ?? null; - const newParentId = event.payload.parentId as string | null; + setTreeData((prev) => + updateSpaceRoots(prev, event.spaceId, (roots) => { + const sourceBefore = treeModel.find(roots, event.payload.id); + if (!sourceBefore) return roots; + const oldParentId = + (sourceBefore as SpaceTreeNode).parentPageId ?? null; + const newParentId = event.payload.parentId as string | null; - const placed = treeModel.place(prev, event.payload.id, { - parentId: newParentId, - index: event.payload.index, - }); - // `place` silently returns the same reference if the destination - // parent isn't loaded on this client. Falling back to removing the - // source keeps the UI consistent (the source will reappear when - // the user expands the new parent and lazy-load fetches it). - if (placed === prev) { - return treeModel.remove(prev, event.payload.id); - } + const placed = treeModel.place(roots, event.payload.id, { + parentId: newParentId, + index: event.payload.index, + }); + // `place` silently returns the same reference if the destination + // parent isn't loaded on this client. Falling back to removing the + // source keeps the UI consistent (the source will reappear when + // the user expands the new parent and lazy-load fetches it). + if (placed === roots) { + return treeModel.remove(roots, event.payload.id); + } - let next = treeModel.update(placed, event.payload.id, { - position: event.payload.position, - parentPageId: newParentId, - } as Partial); + let next = treeModel.update(placed, event.payload.id, { + position: event.payload.position, + parentPageId: newParentId, + } as Partial); - // Mirror the emitter's hasChildren bookkeeping so both clients - // converge to the same chevron state. - if (oldParentId) { - const oldParent = treeModel.find(next, oldParentId); - if (!oldParent?.children?.length) { - next = treeModel.update(next, oldParentId, { - hasChildren: false, + // Mirror the emitter's hasChildren bookkeeping so both clients + // converge to the same chevron state. + if (oldParentId) { + const oldParent = treeModel.find(next, oldParentId); + if (!oldParent?.children?.length) { + next = treeModel.update(next, oldParentId, { + hasChildren: false, + } as Partial); + } + } + if (newParentId) { + next = treeModel.update(next, newParentId, { + hasChildren: true, } as Partial); } - } - if (newParentId) { - next = treeModel.update(next, newParentId, { - hasChildren: true, - } as Partial); - } - return next; - }); + return next; + }), + ); break; case "deleteTreeNode": setTreeData((prev) => {