diff --git a/craft/src/state/PageContext.orphan-repair.test.ts b/craft/src/state/PageContext.orphan-repair.test.ts new file mode 100644 index 0000000..ab70577 --- /dev/null +++ b/craft/src/state/PageContext.orphan-repair.test.ts @@ -0,0 +1,43 @@ +import { describe, test, expect } from 'vitest'; +import { renderEditorHarness } from '../test-utils/editorHarness'; +import { repairOrphanNodes } from '../utils/orphan-repair'; +import { EMPTY_CANVAS } from './PageContext'; + +describe('EMPTY_CANVAS is exported and loadable', () => { + test('deserializing EMPTY_CANVAS gives a ROOT with no children', () => { + const harness = renderEditorHarness({ initialState: EMPTY_CANVAS }); + const nodes = JSON.parse(harness.getSerialized()); + expect(nodes.ROOT.nodes).toEqual([]); + harness.unmount(); + }); +}); + +describe('repaired state survives a real Craft deserialize', () => { + test('an orphaned HTML block becomes a selectable child of ROOT', () => { + const broken = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: {}, tag: 'div' }, + displayName: 'Container', + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null, + }, + stray: { + type: { resolvedName: 'HtmlBlock' }, + isCanvas: false, + props: { code: '
stranded
', style: {} }, + displayName: 'HTML', + custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost', + }, + }); + + const { state, repaired } = repairOrphanNodes(broken); + expect(repaired).toEqual(['stray']); + + const harness = renderEditorHarness({ initialState: state }); + const nodes = JSON.parse(harness.getSerialized()); + expect(nodes.ROOT.nodes).toContain('stray'); + expect(harness.container.textContent).toContain('stranded'); + harness.unmount(); + }); +}); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 2a52ada..41fac54 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -4,6 +4,7 @@ import { PageData, PageSeo } from '../types'; import { SerializedTreeNode } from '../types/sitesmith'; import { useSiteDesign, SiteDesign } from './SiteDesignContext'; import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree'; +import { repairOrphanNodes } from '../utils/orphan-repair'; interface PageContextValue { pages: PageData[]; @@ -81,7 +82,7 @@ export function nextPageId(): string { return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36); } -const EMPTY_CANVAS = +export const EMPTY_CANVAS = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; const EMPTY_HEADER = @@ -368,12 +369,27 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }, [query]); - /** Load a craft state into the Frame */ + /** Load a craft state into the Frame. + * + * Every state goes through `repairOrphanNodes` first: a node that no + * parent lists is invisible to Layers and unselectable on the canvas, so + * it can neither be moved nor deleted. Reattaching it to the end of ROOT + * makes it an ordinary child the user can select and delete. Cheap + * (single JSON round-trip) and a no-op -- returning the identical string + * -- for the overwhelmingly common healthy case. */ const loadState = useCallback( (craftState: string | null, fallback: string) => { setTimeout(() => { + const source = craftState || fallback; + const { state, repaired } = repairOrphanNodes(source); + if (repaired.length > 0) { + console.warn( + `[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`, + repaired.join(', '), + ); + } try { - actions.deserialize(craftState || fallback); + actions.deserialize(state); } catch (e) { console.error('Failed to deserialize state:', e); try {