Files
gitmost/apps/client/src/features/page/tree/utils/utils.ts
claude code agent 227 d181b5c4ff test(temporary-notes): cover the create race-guard, broadcast deadline + cache patch; unify page->tree-node mappers
Address review comment 2159 on the temporary-notes UI work.

Tests:
- tree-model: cover handleCreate's race-guard temporaryExpiresAt patch — (a)
  server node inserted WITHOUT a deadline + create response carries one => node
  gains the deadline; (b) node already has a deadline => not overwritten, prev
  returned by reference.
- ws-tree.service.spec: broadcastPageCreated now asserts the deadline is carried
  when present and pinned to null (`?? null`) when absent.
- page-embed-query (new spec): syncTemporaryExpiresInCache patches the in-tree
  node's temporaryExpiresAt, and leaves the atom value at the same reference when
  the id is absent from the loaded tree (no write).

Refactor (closes the drift bug-class at the root):
- Client: extract one canonical pageToTreeNode(page, overrides) mapper in
  tree/utils and route buildTree, handleCreate's optimistic insert, the restore
  mutation and the duplicate handler through it. Restore stays permanent (server
  nulls temporaryExpiresAt) and duplicate stays permanent (server arms no timer)
  — both now reflect the server without a reload, where before they dropped the
  field entirely.
- Server: extract one toTreeNodeSnapshot(page) helper called by both the
  PAGE_CREATED event enrichment (page.repo) and the addTreeNode broadcast
  (ws-tree.service), so the optional temporaryExpiresAt can't drift between the
  two literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:58:40 +03:00

348 lines
10 KiB
TypeScript

import { IPage } from "@/features/page/types/page.types.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
export function sortPositionKeys(keys: any[]) {
return keys.sort((a, b) => {
if (a.position < b.position) return -1;
if (a.position > b.position) return 1;
return 0;
});
}
/**
* Single canonical `IPage -> SpaceTreeNode` field mapper. Every place that
* materialises a tree node from a page (buildTree, the optimistic insert in
* handleCreate, restore, duplicate) routes through here so the field copy —
* crucially `temporaryExpiresAt` — can never silently drift between sites. The
* `overrides` cover the small per-site differences (e.g. `name: ""` for an
* optimistic create, `name: title || "Untitled"` for restore, `canEdit: true`
* for duplicate). The default `temporaryExpiresAt` comes straight off the page,
* so restore (which the server nulls) stays permanent and a temporary create
* keeps its clock marker without a reload.
*/
export function pageToTreeNode(
page: IPage,
overrides?: Partial<SpaceTreeNode>,
): SpaceTreeNode {
return {
id: page.id,
slugId: page.slugId,
name: page.title,
icon: page.icon,
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
canEdit: page.canEdit ?? page.permissions?.canEdit,
isTemplate: page.isTemplate,
temporaryExpiresAt: page.temporaryExpiresAt,
children: [],
...overrides,
};
}
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
const pageMap: Record<string, SpaceTreeNode> = {};
const tree: SpaceTreeNode[] = [];
pages.forEach((page) => {
pageMap[page.id] = pageToTreeNode(page);
});
// Defense-in-depth: a duplicate id in `pages` would push two references to the
// same node, producing a duplicate React key that crashes the sidebar render.
// Track ids we've already pushed and skip repeats so a stray duplicate from a
// realtime cache write can never break the tree.
const seen = new Set<string>();
pages.forEach((page) => {
if (seen.has(page.id)) return;
seen.add(page.id);
tree.push(pageMap[page.id]);
});
return sortPositionKeys(tree);
}
export function findBreadcrumbPath(
tree: SpaceTreeNode[],
pageId: string,
path: SpaceTreeNode[] = [],
): SpaceTreeNode[] | null {
for (const node of tree) {
if (!node.name || node.name.trim() === "") {
node.name = "Untitled";
}
if (node.id === pageId) {
return [...path, node];
}
if (node.children) {
const newPath = findBreadcrumbPath(node.children, pageId, [
...path,
node,
]);
if (newPath) {
return newPath;
}
}
}
return null;
}
export const updateTreeNodeName = (
nodes: SpaceTreeNode[],
nodeId: string,
newName: string,
): SpaceTreeNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, name: newName };
}
if (node.children && node.children.length > 0) {
return {
...node,
children: updateTreeNodeName(node.children, nodeId, newName),
};
}
return node;
});
};
export const updateTreeNodeIcon = (
nodes: SpaceTreeNode[],
nodeId: string,
newIcon: string,
): SpaceTreeNode[] => {
return nodes.map((node) => {
if (node.id === nodeId) {
return { ...node, icon: newIcon };
}
if (node.children && node.children.length > 0) {
return {
...node,
children: updateTreeNodeIcon(node.children, nodeId, newIcon),
};
}
return node;
});
};
export const deleteTreeNode = (
nodes: SpaceTreeNode[],
nodeId: string,
): SpaceTreeNode[] => {
return nodes
.map((node) => {
if (node.id === nodeId) {
return null;
}
if (node.children && node.children.length > 0) {
return {
...node,
children: deleteTreeNode(node.children, nodeId),
};
}
return node;
})
.filter((node) => node !== null);
};
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
const nodeMap = {};
let result: SpaceTreeNode[] = [];
// Create a reference object for each item with the specified structure
items.forEach((item) => {
nodeMap[item.id] = { ...item, children: [] };
});
// Build the tree array
items.forEach((item) => {
const node = nodeMap[item.id];
// A permission-trimmed response can include a node whose `parentPageId` is
// not in the list (the parent was filtered out server-side). Treat such an
// orphan as a root instead of dereferencing an absent parent and throwing
// "Cannot read properties of undefined". Happy-path behaviour is unchanged:
// a node whose parent IS present still nests under it.
if (item.parentPageId !== null && nodeMap[item.parentPageId]) {
// Find the parent node and add the current node to its children
nodeMap[item.parentPageId].children.push(node);
} else {
// If the item has no parent (or its parent isn't loaded), it's a root
// node, so add it to the result array.
result.push(node);
}
});
result = sortPositionKeys(result);
// Recursively sort the children of each node
function sortChildren(node: SpaceTreeNode) {
if (node.children.length > 0) {
node.hasChildren = true;
node.children = sortPositionKeys(node.children);
node.children.forEach(sortChildren);
}
}
result.forEach(sortChildren);
return result;
}
export function appendNodeChildren(
treeItems: SpaceTreeNode[],
nodeId: string,
children: SpaceTreeNode[],
) {
// Preserve deeper children if they exist and remove node if deleted
return treeItems.map((node) => {
if (node.id === nodeId) {
const newIds = new Set(children.map((c) => c.id));
const existingMap = new Map(
(node.children ?? [])
.filter((c) => newIds.has(c.id))
.map((c) => [c.id, c]),
);
const merged = children.map((newChild) => {
const existing = existingMap.get(newChild.id);
return existing && existing.children
? { ...newChild, children: existing.children }
: newChild;
});
return {
...node,
children: merged,
};
}
if (node.children) {
return {
...node,
children: appendNodeChildren(node.children, nodeId, children),
};
}
return node;
});
}
/**
* Reconcile the loaded root nodes to the authoritative INCOMING set (the
* server's complete current roots for the space), preserving any lazy-loaded
* children/subtree of a root that still exists.
*
* This runs only once all root pages are fetched, so `incomingRoots` is the full
* server root set and is authoritative for WHICH roots exist:
* - a root in BOTH: kept, with its own fields refreshed from `incoming` (so a
* rename/move during a gap shows) while PRESERVING its previously lazy-loaded
* `children` (expanded subtrees + open-state survive a refetch);
* - a root only in `incoming`: a new root, added as-is;
* - a root only in `prev`: it was DELETED or moved under another page while we
* were not receiving events (e.g. a socket reconnect after a sleep/wifi gap).
* It is DROPPED instead of lingering as a 404 "ghost" root (#159 #2). The old
* append-only merge kept it forever.
*/
export function mergeRootTrees(
prevRoots: SpaceTreeNode[],
incomingRoots: SpaceTreeNode[],
): SpaceTreeNode[] {
const prevById = new Map(prevRoots.map((r) => [r.id, r]));
const reconciled = incomingRoots.map((incoming) => {
const prev = prevById.get(incoming.id);
// Preserve the previously loaded children/subtree (the root query returns
// only top-level roots, so `incoming` carries no children); refresh the
// node's own fields from the authoritative incoming copy.
return prev ? { ...incoming, children: prev.children } : incoming;
});
return sortPositionKeys(reconciled);
}
/**
* Ids of branches a socket-reconnect refresh should re-fetch and reconcile
* (#159 #8): a node that is currently OPEN and whose children are LOADED
* (`children` is an array — possibly empty). An unloaded branch (`children ===
* undefined`) is skipped because lazy-load fetches it fresh on the next expand,
* so there is nothing stale to reconcile. Walks the whole tree (a deep open
* chain refreshes every loaded level).
*/
export function loadedOpenBranchIds(
tree: SpaceTreeNode[],
openIds: ReadonlySet<string>,
): string[] {
const ids: string[] = [];
const walk = (nodes: SpaceTreeNode[]) => {
for (const n of nodes) {
if (openIds.has(n.id) && Array.isArray(n.children)) ids.push(n.id);
if (n.children) walk(n.children);
}
};
walk(tree);
return ids;
}
// Collect every node id in the tree (roots, branches, leaves). Used by
// collapseAll to clear the open-state map for all current-space nodes.
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
ids.push(n.id);
if (n.children?.length) walk(n.children);
}
};
walk(nodes);
return ids;
}
// Collect ids of branch nodes (nodes that have children). Used by expandAll to
// open every branch in the open-state map; leaves need no entry.
export function collectBranchIds(nodes: SpaceTreeNode[]): string[] {
const ids: string[] = [];
const walk = (list: SpaceTreeNode[]) => {
for (const n of list) {
if (n.children?.length) {
ids.push(n.id);
walk(n.children);
}
}
};
walk(nodes);
return ids;
}
// The open-state map (`openTreeNodesAtom`) is shared across spaces. Pure
// next-map helpers for expand/collapse so the merge logic can be unit-tested
// without rendering SpaceTree. Both return a fresh map and never mutate the
// input — ids not in `ids` (e.g. other spaces) are carried over untouched.
// Set each id in `ids` to true (open). Pre-existing entries (including other
// spaces' open state) are preserved.
export function openBranches(
prevMap: Record<string, boolean>,
ids: string[],
): Record<string, boolean> {
const next = { ...prevMap };
for (const id of ids) next[id] = true;
return next;
}
// Set each id in `ids` to false (closed). Entries not listed (e.g. other
// spaces' ids) are left exactly as they were.
export function closeIds(
prevMap: Record<string, boolean>,
ids: string[],
): Record<string, boolean> {
const next = { ...prevMap };
for (const id of ids) next[id] = false;
return next;
}