diff --git a/craft/src/state/PageContext.treeToState.test.ts b/craft/src/state/PageContext.treeToState.test.ts index 2fd1986..df4ae58 100644 --- a/craft/src/state/PageContext.treeToState.test.ts +++ b/craft/src/state/PageContext.treeToState.test.ts @@ -96,12 +96,57 @@ describe('treeToCraftState resolvedName guard', () => { expect(parsed.ROOT.type.resolvedName).toBe('ColumnLayout'); expect(parsed.ROOT.linkedNodes).toEqual({ 'col-0': 'a', 'col-1': 'b' }); expect(parsed.ROOT.props.columns).toBe(2); - // NB: the ROOT-aliasing step only reassigns `nodes[]` children's parent, - // not `linkedNodes` children's — a pre-existing quirk (not introduced by - // this refactor, and out of scope for E3) that leaves column children's - // `parent` pointing at the tree's original (pre-alias) root id. - expect(parsed['a'].parent).toBe('cols-1'); expect(parsed['a'].isCanvas).toBe(true); + // I-2: the ROOT-aliasing step must reparent linkedNodes children too, not + // just nodes[] children. Before the fix, 'a'/'b'.parent stayed pointing + // at the tree's original (pre-alias) root id ('cols-1'), which is then + // `delete`d from the output -- a dangling parent reference that breaks + // select/move/delete of those nodes in the Craft.js editor. + expect(parsed['a'].parent).toBe('ROOT'); + expect(parsed['b'].parent).toBe('ROOT'); + expect(parsed['cols-1']).toBeUndefined(); + }); + + test('I-2: every child parent in the output tree references an id that exists (no dangling reference to the deleted old-root id) -- ColumnLayout root', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'ColumnLayout' }, + props: { node_id: 'cols-1' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'a' }, nodes: [] }, + { type: { resolvedName: 'Heading' }, props: { node_id: 'b' }, nodes: [] }, + ], + }; + const parsed = JSON.parse(treeToCraftState(tree)); + for (const id of Object.keys(parsed)) { + const parent = parsed[id].parent; + if (parent == null) continue; + expect(parsed[parent]).toBeDefined(); + } + }); + + test('I-2: a Section (SHELL_INNER) root reparents its section-inner linkedNode child to ROOT', () => { + const tree: SerializedTreeNode = { + type: { resolvedName: 'Section' }, + props: { node_id: 'sec-root' }, + nodes: [ + { type: { resolvedName: 'Heading' }, props: { node_id: 'h-1', text: 'Hi' }, nodes: [] }, + ], + }; + const parsed = JSON.parse(treeToCraftState(tree)); + + const innerId = parsed.ROOT.linkedNodes['section-inner']; + expect(innerId).toBeDefined(); + // The section-inner node itself is a linkedNodes child of the + // (re-keyed) root -- its parent must point at 'ROOT', not the deleted + // original root id 'sec-root'. + expect(parsed[innerId].parent).toBe('ROOT'); + expect(parsed['sec-root']).toBeUndefined(); + // And every parent reference in the whole tree resolves to a real node. + for (const id of Object.keys(parsed)) { + const parent = parsed[id].parent; + if (parent == null) continue; + expect(parsed[parent]).toBeDefined(); + } }); test('style: [] normalizes to {} (regression)', () => { diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 0419674..12474e5 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -86,6 +86,16 @@ export function treeToCraftState(tree: SerializedTreeNode): string { for (const childId of nodes['ROOT'].nodes) { if (nodes[childId]) nodes[childId].parent = 'ROOT'; } + // I-2: linkedNodes children (e.g. ColumnLayout's col-0/col-1, or a + // Section/BackgroundSection/FormContainer's SHELL_INNER wrapper) need + // the same reparenting as nodes[] children above. Without this, a + // ColumnLayout/SHELL_INNER-rooted AI `replace` leaves those children's + // `parent` pointing at the OLD root id, which is then `delete`d -- + // producing a dangling parent reference that breaks select/move/delete + // of those nodes in the Craft.js editor. + for (const linkedId of Object.values(nodes['ROOT'].linkedNodes)) { + if (nodes[linkedId]) nodes[linkedId].parent = 'ROOT'; + } } return JSON.stringify(nodes); }