feat(site-builder): add pure orphan-node detection and repair

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 06:36:39 -07:00
co-authored by Claude Opus 5
parent 66db1db507
commit f0a1508acd
2 changed files with 185 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
import { describe, test, expect } from 'vitest';
import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair';
/** Minimal Craft-shaped node. */
function node(over: Record<string, any> = {}) {
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([]);
});
});
+79
View File
@@ -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, any>): string[] {
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return [];
const reachable = new Set<string>([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<string, any>;
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 };
}