sitesmith: pre-create section-inner/bg-section-inner/form-inner linkedNodes

The Invariant 'component type (undefined) does not exist in the resolver'
was Craft.js's toNodeTree choking on the linkedNode that <Element id="X">
auto-creates at render time inside Section / BackgroundSection /
FormContainer. The auto-created node stores its type as the Container
React component class itself, not as {resolvedName:'Container'}, so the
later type.resolvedName lookup returns undefined.

For each shell, treeToState (and apply-ai-response's buildNodeTree) now
synthesizes the linkedNode container up-front with a proper serialized
type, moves the AI's direct children into it, and reparents them. This
matches the canonical shape Craft.js writes when the user manually builds
a site, so Craft.js never has to materialize the linkedNode itself.
This commit is contained in:
2026-05-24 17:22:40 -07:00
parent a1ec51afc3
commit 87dd4340f7
2 changed files with 94 additions and 0 deletions

View File

@@ -12,6 +12,18 @@ import { useSiteDesign, SiteDesign } from './SiteDesignContext';
* because the shell claims to be a canvas but its render ignores `nodes`. */
const CANVAS_TYPES = new Set<string>(['Container']);
/** Shells that wrap their content in a single <Element id="<key>" is={Container}>.
* When the AI puts content directly under one of these, the children end up
* orphaned (the shell ignores data.nodes — it renders via the linkedNode) and
* Craft.js auto-creates the linkedNode at render time with a botched type
* field, which then crashes toNodeTree. Pre-create the linkedNode ourselves
* to keep the state shape Craft.js expects. */
const SHELL_INNER: Record<string, string> = {
Section: 'section-inner',
BackgroundSection: 'bg-section-inner',
FormContainer: 'form-inner',
};
interface PageContextValue {
pages: PageData[];
headerPage: PageData;
@@ -340,9 +352,56 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (!cur.columns || cur.columns !== childIds.length) cur.columns = childIds.length;
(nodes[id] as any).props = cur;
}
// Section/BackgroundSection/FormContainer each render a single
// <Element id="<key>" is={Container} canvas> ... </Element>. If the AI
// nests content as direct children, Craft.js will auto-create the
// linkedNode on first render — and store its type as the Container
// component class rather than {resolvedName:'Container'}, which then
// crashes toNodeTree with "type (undefined) does not exist in resolver".
// Pre-create the linkedNode ourselves with the correct serialized type
// so Craft.js never has to materialize it.
const innerKey = SHELL_INNER[typeName ?? ''];
if (innerKey && childIds.length > 0) {
const innerId = `${id}__${innerKey}`;
nodes[innerId] = {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
parent: id,
nodes: [...childIds],
linkedNodes: {},
};
for (const cid of childIds) {
if (nodes[cid]) (nodes[cid] as any).parent = innerId;
}
(nodes[id] as any).nodes = [];
(nodes[id] as any).linkedNodes = { [innerKey]: innerId };
}
return id;
};
const rootId = walk(tree, null);
// DIAGNOSTIC: dump the produced state + scan for type=undefined nodes
// so when the resolver invariant fires we can see which node is bad.
try {
const w = window as any;
w.__sitesmithLastTree = tree;
w.__sitesmithLastState = nodes;
const bad: Array<{id:string; node:any}> = [];
for (const [nid, n] of Object.entries(nodes)) {
const t = (n as any).type;
const name = t && typeof t === 'object' ? t.resolvedName : t;
if (!name) bad.push({ id: nid, node: n });
}
if (bad.length > 0) {
// eslint-disable-next-line no-console
console.warn('[SITESMITH] nodes with undefined type:', bad);
}
// eslint-disable-next-line no-console
console.log('[SITESMITH] treeToState produced', Object.keys(nodes).length, 'nodes; rootId=', rootId);
} catch (_e) { /* diagnostic only */ }
// Craft.js deserialize requires the root node keyed as 'ROOT'
if (rootId !== 'ROOT') {
nodes['ROOT'] = nodes[rootId];