From 6428f93cecbf89d2bc6a27f9fb3a3953884892b2 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 24 May 2026 16:17:25 -0700 Subject: [PATCH] sitesmith: route ColumnLayout children through linkedNodes (Invariant fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ColumnLayout's render uses which expects the columns to live in linkedNodes, not data.nodes. The AI nests its column containers as direct children, so they'd land in data.nodes — Craft.js's render ignores them (the layout draws fresh empty Elements), but the orphaned children remain in state with parent: . Any subsequent toNodeTree walk then trips on this inconsistency and the uncaught Invariant kills the editor. Normalizer added in two places — treeToState (for scope=site/page replaces) and buildNodeTree (for scope=section inserts and patch ops): when we see a ColumnLayout with direct children, move them into linkedNodes keyed col-0/col-1/col-2..., clear data.nodes, set the column nodes' isCanvas to true (they hold content), and sync the "columns" prop to the actual count. --- craft/src/state/PageContext.tsx | 18 ++++++++++++++++++ craft/src/utils/apply-ai-response.ts | 14 ++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 1bf67df..9b73e15 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -325,6 +325,24 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => for (const child of node.nodes ?? []) { childIds.push(walk(child, id)); } + // ColumnLayout uses Craft.js linkedNodes with fixed ids (col-0, col-1, ...). + // The AI emits children as direct `nodes`, but ColumnLayout's render ignores + // them and creates fresh column Elements — the AI's children become orphans + // and any subsequent toNodeTree walk hits an Invariant. Move direct children + // into linkedNodes so they render in the columns the user actually sees. + if (typeName === 'ColumnLayout' && childIds.length > 0) { + const linked: Record = {}; + childIds.forEach((cid, i) => { + linked[`col-${i}`] = cid; + if (nodes[cid]) (nodes[cid] as any).isCanvas = true; // columns are canvases + }); + (nodes[id] as any).nodes = []; + (nodes[id] as any).linkedNodes = linked; + // Reflect the actual column count on the component so its render matches. + const cur = (nodes[id] as any).props || {}; + if (!cur.columns || cur.columns !== childIds.length) cur.columns = childIds.length; + (nodes[id] as any).props = cur; + } return id; }; const rootId = walk(tree, null); diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 260e78d..99aadab 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -86,6 +86,20 @@ function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree { const childId = walk(child, id); craftNodes[id].data.nodes.push(childId); } + // ColumnLayout uses linkedNodes (col-0, col-1, ...) — not direct children. + if (node.type.resolvedName === 'ColumnLayout' && craftNodes[id].data.nodes.length > 0) { + const linked: Record = {}; + craftNodes[id].data.nodes.forEach((cid: string, i: number) => { + linked[`col-${i}`] = cid; + if (craftNodes[cid]) craftNodes[cid].data.isCanvas = true; + }); + craftNodes[id].data.nodes = []; + craftNodes[id].data.linkedNodes = linked; + const colCount = Object.keys(linked).length; + if (!craftNodes[id].data.props.columns || craftNodes[id].data.props.columns !== colCount) { + craftNodes[id].data.props.columns = colCount; + } + } return id; };