fix(canvas): re-sort parents-before-children after nest mutation

React Flow requires parent nodes to appear before their children in
the nodes array. When they don't, it logs "Parent node {id} not
found. Please make sure that parent nodes are in front of their
child nodes in the nodes array" and — more importantly — renders
the child at canvas-absolute coords instead of parent-relative,
flashing it far outside the parent.

topology's buildNodesAndEdges already enforced this at hydrate, but
nestNode + batchNest weren't re-sorting after mutating parentId.
A freshly-nested child often ended up after-first-drag at the
wrong screen position because its new parent sat later in the
array than itself.

Extract sortParentsBeforeChildren() into canvas-topology as a
reusable DFS visit; call it at the tail of both nestNode's set()
and batchNest's commit set(). 923 tests still green — no behaviour
change beyond eliminating the warning and the position flash.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 21:00:40 -07:00
parent 2a8977c946
commit 2d6ff11c4e
2 changed files with 40 additions and 0 deletions

View File

@ -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<T extends { id: string; parentId?: string }>(
nodes: T[],
): T[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
const visited = new Set<string>();
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

View File

@ -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<CanvasState>((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<CanvasState>((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