diff --git a/craft/src/utils/orphan-repair.test.ts b/craft/src/utils/orphan-repair.test.ts new file mode 100644 index 0000000..5110b8e --- /dev/null +++ b/craft/src/utils/orphan-repair.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from 'vitest'; +import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair'; + +/** Minimal Craft-shaped node. */ +function node(over: Record = {}) { + return { + type: { resolvedName: 'Container' }, + isCanvas: false, + props: {}, + displayName: 'Container', + custom: {}, + hidden: false, + nodes: [], + linkedNodes: {}, + parent: 'ROOT', + ...over, + }; +} + +const healthy = JSON.stringify({ + ROOT: node({ isCanvas: true, parent: null, nodes: ['a'] }), + a: node({ parent: 'ROOT' }), +}); + +describe('findUnreachableNodeIds', () => { + test('a healthy tree has no unreachable nodes', () => { + expect(findUnreachableNodeIds(JSON.parse(healthy))).toEqual([]); + }); + + test('a node ROOT does not list is unreachable even when its parent says ROOT', () => { + const nodes = JSON.parse(healthy); + nodes.stray = node({ parent: 'ROOT', displayName: 'HTML' }); + expect(findUnreachableNodeIds(nodes)).toEqual(['stray']); + }); + + test('a node whose parent no longer exists is unreachable', () => { + const nodes = JSON.parse(healthy); + nodes.stray = node({ parent: 'ghost' }); + expect(findUnreachableNodeIds(nodes)).toEqual(['stray']); + }); + + test('children of an unreachable node are also unreachable', () => { + const nodes = JSON.parse(healthy); + nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] }); + nodes.strayChild = node({ parent: 'stray' }); + expect(findUnreachableNodeIds(nodes).sort()).toEqual(['stray', 'strayChild']); + }); + + test('linkedNodes children count as reachable', () => { + const nodes = JSON.parse(healthy); + nodes.ROOT.linkedNodes = { inner: 'linked' }; + nodes.linked = node({ parent: 'ROOT' }); + expect(findUnreachableNodeIds(nodes)).toEqual([]); + }); + + test('a cycle among orphans terminates instead of hanging', () => { + const nodes = JSON.parse(healthy); + nodes.x = node({ parent: 'y', nodes: ['y'] }); + nodes.y = node({ parent: 'x', nodes: ['x'] }); + expect(findUnreachableNodeIds(nodes).sort()).toEqual(['x', 'y']); + }); +}); + +describe('repairOrphanNodes', () => { + test('a healthy tree is returned byte-identical with nothing repaired', () => { + const out = repairOrphanNodes(healthy); + expect(out.repaired).toEqual([]); + expect(out.state).toBe(healthy); + }); + + test('an orphan is appended to the end of ROOT.nodes and reparented', () => { + const nodes = JSON.parse(healthy); + nodes.stray = node({ parent: 'ghost', displayName: 'HTML' }); + const out = repairOrphanNodes(JSON.stringify(nodes)); + + expect(out.repaired).toEqual(['stray']); + const parsed = JSON.parse(out.state); + expect(parsed.ROOT.nodes).toEqual(['a', 'stray']); + expect(parsed.stray.parent).toBe('ROOT'); + }); + + test('only the top of an orphan subtree is reattached; its children ride along', () => { + const nodes = JSON.parse(healthy); + nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] }); + nodes.strayChild = node({ parent: 'stray' }); + const out = repairOrphanNodes(JSON.stringify(nodes)); + + expect(out.repaired).toEqual(['stray']); + const parsed = JSON.parse(out.state); + expect(parsed.ROOT.nodes).toEqual(['a', 'stray']); + expect(parsed.strayChild.parent).toBe('stray'); + }); + + test('malformed JSON comes back unchanged rather than throwing', () => { + const out = repairOrphanNodes('{not json'); + expect(out.state).toBe('{not json'); + expect(out.repaired).toEqual([]); + }); + + test('state with no ROOT comes back unchanged', () => { + const orphanOnly = JSON.stringify({ a: node({ parent: null }) }); + const out = repairOrphanNodes(orphanOnly); + expect(out.state).toBe(orphanOnly); + expect(out.repaired).toEqual([]); + }); +}); diff --git a/craft/src/utils/orphan-repair.ts b/craft/src/utils/orphan-repair.ts new file mode 100644 index 0000000..3811a06 --- /dev/null +++ b/craft/src/utils/orphan-repair.ts @@ -0,0 +1,79 @@ +/** + * Editor-state integrity: every node must be reachable from ROOT. + * + * A node that exists in `SerializedNodes` but appears in no parent's `nodes` + * or `linkedNodes` list is invisible to the Layers tree AND to Craft's own + * selection machinery -- it renders somewhere on the canvas but can't be + * selected or deleted, which is exactly the "dropped outside the page" + * report. Reachability is computed from the PARENT'S child lists, not from + * each node's own `parent` pointer: a stale `parent: 'ROOT'` on a node ROOT + * never lists is precisely the broken case we're looking for. + * + * Pure functions over serialized state -- no React, no Craft instance. + */ + +const ROOT_ID = 'ROOT'; + +function childIdsOf(node: any): string[] { + const nodes: string[] = Array.isArray(node?.nodes) ? node.nodes : []; + const linked: string[] = node?.linkedNodes ? Object.values(node.linkedNodes) : []; + return [...nodes, ...linked]; +} + +/** Ids of nodes present in `nodes` whose parent chain does not reach 'ROOT'. */ +export function findUnreachableNodeIds(nodes: Record): string[] { + if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return []; + + const reachable = new Set([ROOT_ID]); + const queue: string[] = [ROOT_ID]; + + while (queue.length > 0) { + const id = queue.shift()!; + for (const childId of childIdsOf(nodes[id])) { + // The `reachable` guard also terminates on a cycle among real nodes. + if (typeof childId === 'string' && nodes[childId] && !reachable.has(childId)) { + reachable.add(childId); + queue.push(childId); + } + } + } + + return Object.keys(nodes).filter((id) => !reachable.has(id)); +} + +/** Reattach every unreachable node to the end of ROOT.nodes. Returns the + * possibly-rewritten serialized state and the ids that were moved. Never + * throws: malformed input comes back unchanged with an empty `repaired`. */ +export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] } { + let nodes: Record; + try { + nodes = JSON.parse(serialized); + } catch { + // A page must never fail to load because the repair pass couldn't parse + // it -- hand the original string straight back to deserialize(). + return { state: serialized, repaired: [] }; + } + + if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) { + return { state: serialized, repaired: [] }; + } + + const unreachable = findUnreachableNodeIds(nodes); + if (unreachable.length === 0) return { state: serialized, repaired: [] }; + + // Reattach only the TOP of each orphan subtree. An orphan whose parent is + // itself an orphan keeps its existing parent and rides along. + const orphanSet = new Set(unreachable); + const tops = unreachable.filter((id) => { + const parent = nodes[id]?.parent; + return !(typeof parent === 'string' && orphanSet.has(parent)); + }); + + if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = []; + for (const id of tops) { + nodes[id].parent = ROOT_ID; + nodes[ROOT_ID].nodes.push(id); + } + + return { state: JSON.stringify(nodes), repaired: tops }; +}