Files
site-builder/craft/src/utils/orphan-repair.ts
T

80 lines
3.0 KiB
TypeScript
Raw Normal View History

/**
* 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 };
}