/** * 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 -- but not because it "renders but can't be selected". * Craft.js's `` only instantiates nodes it can actually walk to via * `data.nodes`/`linkedNodes` starting from ROOT, so an unreachable node is * never rendered at all: it doesn't appear on the canvas, it's just inert * data sitting in the serialized state. 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. * * This is a DIFFERENT mechanism from the originally-reported symptom -- a * *visible* element on the canvas that can't be selected or deleted. That * symptom requires the node to be both rendered AND excluded from Craft's * selection/interaction machinery, which is not what an unreachable-from- * ROOT node produces (it isn't rendered at all). This repair fixes the * "node is present in state but invisible/unrecoverable" case; the * originally-reported "visible but unselectable" case is still * unreproduced and is presumed to be a different bug. * * 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`. * * Invariant: for any input that parses and has a ROOT, calling * `findUnreachableNodeIds` on the returned `state` (parsed) always yields * `[]` -- there is no orphan configuration this leaves half-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: [] }; } let unreachable = findUnreachableNodeIds(nodes); if (unreachable.length === 0) return { state: serialized, repaired: [] }; if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = []; const repaired: string[] = []; // Loop because a single pass can leave orphan CYCLES untouched: if every // member of a cluster points only at other members of that same cluster, // none of them has a parent pointing "out", so nothing qualifies as a top // and a one-shot pass would report `repaired: []` while the cluster is // still unreachable. Each iteration re-derives `unreachable` from the // current (partially repaired) state and terminates once it's empty -- // this is what makes the invariant hold rather than just being hoped for. while (unreachable.length > 0) { const orphanSet = new Set(unreachable); // Reattach the TOP of each orphan subtree: a node whose `parent` points // outside the current orphan set (to something real, to nothing, or is // null). Its existing child list carries the rest of its subtree along // for free once BFS can reach it again. let tops = unreachable.filter((id) => { const parent = nodes[id]?.parent; return !(typeof parent === 'string' && orphanSet.has(parent)); }); // No such node exists only when every remaining orphan's `parent` // points at another orphan -- i.e. a closed cycle (2+ nodes referencing // only each other). There is no legitimate "outside" anchor to prefer, // so break the cycle by force-reattaching one representative member // (first in iteration order, for determinism). Because a cycle is // strongly connected via the actual nodes/linkedNodes edges, reattaching // any single member pulls the rest of that cycle in on the next // `findUnreachableNodeIds` pass without touching their `parent` fields. if (tops.length === 0) { tops = [unreachable[0]]; } for (const id of tops) { nodes[id].parent = ROOT_ID; nodes[ROOT_ID].nodes.push(id); repaired.push(id); } unreachable = findUnreachableNodeIds(nodes); } return { state: JSON.stringify(nodes), repaired }; }