fix(site-builder): repair orphan nodes on initial page load too
useWhpApi's load() called actions.deserialize() directly on the first page's stored craftState, bypassing repairOrphanNodes -- unlike PageContext.loadState, which runs it on every subsequent page switch. An orphaned node (unreachable from ROOT, invisible to Layers/selection) in a saved project would get silently repaired on the next page switch but not on the load that actually renders it first. Route the initial deserialize through the same repair call, with the same console.warn, so both paths behave identically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { PageProvider, usePages } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { useWhpApi } from './useWhpApi';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Task 13b: `PageContext.loadState` runs every stored craft state through
|
||||
* `repairOrphanNodes` before handing it to `actions.deserialize` -- that
|
||||
* covers every page SWITCH (see `PageContext.orphan-repair-wiring.test.tsx`,
|
||||
* the model for this file). But the INITIAL load -- `useWhpApi`'s `load()`,
|
||||
* fired once on mount by `TopBar.tsx` -- used to call
|
||||
* `actions.deserialize(state)` directly on the first page's stored
|
||||
* `craftState`, bypassing repair entirely. A site whose saved state
|
||||
* contains a node unreachable from ROOT would get it silently repaired on
|
||||
* the NEXT page switch but not on the load that actually renders it first.
|
||||
*
|
||||
* This mocks `@craftjs/core` the same way `useWhpApi.load.test.tsx` and
|
||||
* `PageContext.orphan-repair-wiring.test.tsx` do, and asserts on the exact
|
||||
* string handed to the mocked `actions.deserialize` -- the orphan must be
|
||||
* reattached to ROOT and a `console.warn` must fire, exactly like the
|
||||
* page-switch path.
|
||||
*/
|
||||
const deserializeMock = vi.fn();
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => '{}' },
|
||||
actions: { deserialize: deserializeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'testuser',
|
||||
apiUrl: '/panel/api/site-builder',
|
||||
csrfToken: 'tok',
|
||||
siteId: 42,
|
||||
siteDomain: 'example.com',
|
||||
siteName: 'Test Site',
|
||||
backUrl: '/panel/sites',
|
||||
isRoot: false,
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
interface Captured {
|
||||
load: ReturnType<typeof useWhpApi>['load'];
|
||||
pages: ReturnType<typeof usePages>['pages'];
|
||||
}
|
||||
|
||||
function render(): { get: () => Captured } {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let captured: Captured | null = null;
|
||||
|
||||
const Consumer: React.FC = () => {
|
||||
const { load } = useWhpApi();
|
||||
const { pages } = usePages();
|
||||
captured = { load, pages };
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return { get: () => captured! };
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
/** Same shape as `PageContext.orphan-repair-wiring.test.tsx`'s ORPHAN_STATE:
|
||||
* a ROOT with no children plus an orphan ('stray') whose `parent` points at
|
||||
* an id that doesn't exist in the tree, and which no node's `nodes`/
|
||||
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
|
||||
const ORPHAN_STATE = 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',
|
||||
},
|
||||
});
|
||||
|
||||
describe('useWhpApi load() repairs an orphaned node on the FIRST page before deserializing', () => {
|
||||
beforeEach(() => {
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
design: null,
|
||||
header_craft_state: null,
|
||||
footer_craft_state: null,
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: ORPHAN_STATE },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const harness = render();
|
||||
|
||||
await act(async () => {
|
||||
await harness.get().load();
|
||||
});
|
||||
|
||||
expect(deserializeMock).toHaveBeenCalled();
|
||||
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
|
||||
const parsed = JSON.parse(passedState);
|
||||
// The orphan is now an ordinary, reachable child of ROOT.
|
||||
expect(parsed.ROOT.nodes).toContain('stray');
|
||||
expect(parsed.stray.parent).toBe('ROOT');
|
||||
|
||||
// The observable signal that repair actually ran, not just that the
|
||||
// orphan happened to be absent for some unrelated reason.
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('reattached');
|
||||
|
||||
warnSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { PageData } from '../types';
|
||||
|
||||
export interface BuildSavePayloadInput {
|
||||
@@ -305,12 +306,26 @@ export function useWhpApi() {
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||
})));
|
||||
|
||||
// Load the first page (home) into the canvas
|
||||
// Load the first page (home) into the canvas. Routed through
|
||||
// `repairOrphanNodes` first -- same as `PageContext.loadState` does
|
||||
// for every subsequent page switch -- so a node that's unreachable
|
||||
// from ROOT (invisible to Layers/selection) gets reattached here
|
||||
// too, on the load that actually puts it on screen, rather than only
|
||||
// on the next page switch. `repairOrphanNodes` never throws and
|
||||
// returns the original string reference when nothing needed fixing,
|
||||
// so this is cheap to run unconditionally.
|
||||
const firstPage = proj.pages_craft_state[0];
|
||||
if (firstPage.craftState) {
|
||||
try {
|
||||
const state = typeof firstPage.craftState === 'string'
|
||||
const rawState = typeof firstPage.craftState === 'string'
|
||||
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
||||
const { state, repaired } = repairOrphanNodes(rawState);
|
||||
if (repaired.length > 0) {
|
||||
console.warn(
|
||||
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
|
||||
repaired.join(', '),
|
||||
);
|
||||
}
|
||||
actions.deserialize(state);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load page state:', e);
|
||||
|
||||
Reference in New Issue
Block a user