fix(site-builder): reattach unreachable nodes when loading a page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: '<p>stranded</p>', 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { PageData, PageSeo } from '../types';
|
|||||||
import { SerializedTreeNode } from '../types/sitesmith';
|
import { SerializedTreeNode } from '../types/sitesmith';
|
||||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||||
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
||||||
|
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||||
|
|
||||||
interface PageContextValue {
|
interface PageContextValue {
|
||||||
pages: PageData[];
|
pages: PageData[];
|
||||||
@@ -81,7 +82,7 @@ export function nextPageId(): string {
|
|||||||
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
|
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":{}}}';
|
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||||
|
|
||||||
const EMPTY_HEADER =
|
const EMPTY_HEADER =
|
||||||
@@ -368,12 +369,27 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
}, [query]);
|
}, [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(
|
const loadState = useCallback(
|
||||||
(craftState: string | null, fallback: string) => {
|
(craftState: string | null, fallback: string) => {
|
||||||
setTimeout(() => {
|
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 {
|
try {
|
||||||
actions.deserialize(craftState || fallback);
|
actions.deserialize(state);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to deserialize state:', e);
|
console.error('Failed to deserialize state:', e);
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user