From 1a88baa95ded3662e1e68ac162e30a7bef83cede Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 12:25:22 -0700 Subject: [PATCH] fix(builder): deep-clone node data on id regeneration to avoid shared props regenerateTreeIds shallow-copied each node's data, leaving data.props (and data.custom) as the same object reference between the original node and its duplicate/pasted copy. Craft.js's setProp mutates data.props in place, so editing the duplicate's props silently mutated the original too. Deep-clone data via structuredClone before applying id remaps so no mutable sub-object is shared between original and regenerated nodes. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/utils/craft-tree.test.ts | 17 +++++++++++++++++ craft/src/utils/craft-tree.ts | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/craft/src/utils/craft-tree.test.ts b/craft/src/utils/craft-tree.test.ts index 859d786..fe6ce06 100644 --- a/craft/src/utils/craft-tree.test.ts +++ b/craft/src/utils/craft-tree.test.ts @@ -129,4 +129,21 @@ describe('regenerateTreeIds', () => { regenerateTreeIds(input); expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot); }); + + test('output node data.props is a deep clone, not shared with the input node', () => { + const input = makeTree(); + input.nodes['root-1'].data.props = { alignment: 'left' }; + + const output = regenerateTreeIds(input); + const outputRoot = output.nodes[output.rootNodeId]; + + // Clone must preserve content at the time of cloning. + expect(outputRoot.data.props).toEqual({ alignment: 'left' }); + + // Mutating the output's props (simulating Craft.js's in-place setProp) + // must NOT affect the input node's props object. + (outputRoot.data.props as { alignment: string }).alignment = 'right'; + + expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' }); + }); }); diff --git a/craft/src/utils/craft-tree.ts b/craft/src/utils/craft-tree.ts index a4c3d72..aa08659 100644 --- a/craft/src/utils/craft-tree.ts +++ b/craft/src/utils/craft-tree.ts @@ -43,11 +43,18 @@ export function regenerateTreeIds(tree: NodeTree): NodeTree { newLinkedNodes[slot] = remapId(linkedId); } + // Deep-clone data so mutable sub-objects (props, custom, etc.) are never + // shared by reference between the original node and its regenerated + // copy. Craft.js's setProp mutates data.props in place, so a shallow + // copy here would let edits to the duplicate silently corrupt the + // original. + const clonedData = structuredClone(oldNode.data); + const newNode: Node = { ...oldNode, id: newId, data: { - ...oldNode.data, + ...clonedData, parent: newParent, nodes: (oldNode.data.nodes || []).map(remapId), linkedNodes: newLinkedNodes,