diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index 4a724578..81c26c24 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -9,6 +9,35 @@ const V_SPACING = 200; // (first render, before React Flow reports dimensions). These match the // min-width / min-height that WorkspaceNode.tsx sets, so a parent built // from them will never start too small for its children on first paint. +/** + * Re-orders a React Flow node array so parents always appear BEFORE + * their children. React Flow requires this ordering; when it's + * violated RF logs "Parent node ... not found" and renders the child + * at canvas-absolute coords (losing the parent-relative transform). + * + * We call this every time nestNode / batchNest mutates parentId — + * without a re-sort a freshly-nested child can appear AFTER its new + * parent in the array, which breaks the next drag. + */ +export function sortParentsBeforeChildren( + nodes: T[], +): T[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + const visited = new Set(); + const out: T[] = []; + const visit = (n: T) => { + if (visited.has(n.id)) return; + if (n.parentId) { + const parent = byId.get(n.parentId); + if (parent && !visited.has(parent.id)) visit(parent); + } + visited.add(n.id); + out.push(n); + }; + for (const n of nodes) visit(n); + return out; +} + export const CHILD_DEFAULT_WIDTH = 260; export const CHILD_DEFAULT_HEIGHT = 140; export const PARENT_HEADER_PADDING = 60; // room for the parent's own header diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index d13758c9..649647a8 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -13,6 +13,7 @@ import { buildNodesAndEdges, computeAutoLayout, defaultChildSlot, + sortParentsBeforeChildren, CHILD_DEFAULT_HEIGHT, CHILD_DEFAULT_WIDTH, PARENT_BOTTOM_PADDING, @@ -482,6 +483,10 @@ export const useCanvasStore = create((set, get) => ({ (e) => !movedIds.has(e.source) && !movedIds.has(e.target), ), }); + // Keep parents before children in the array (same invariant + // nestNode enforces). Needed after multi-select re-parent because + // the selection order is user-driven. + set({ nodes: sortParentsBeforeChildren(get().nodes) }); // Fire every PATCH in parallel. Individual failures roll back just // that node (others remain committed, matching the single-node @@ -684,6 +689,12 @@ export const useCanvasStore = create((set, get) => ({ }), edges: newEdges, }); + // React Flow requires parents before children in the array. Without + // this re-sort a newly-nested child can end up ahead of its new + // parent, which makes RF log "Parent node not found" and render the + // child at canvas-absolute coords (far outside the parent, which + // is the flash-bug the user just flagged). + set({ nodes: sortParentsBeforeChildren(get().nodes) }); try { // One round-trip per nest: the /workspaces/:id PATCH handler