diff --git a/craft/src/hooks/useWhpApi.orphan-repair-wiring.test.tsx b/craft/src/hooks/useWhpApi.orphan-repair-wiring.test.tsx new file mode 100644 index 0000000..e89c1a7 --- /dev/null +++ b/craft/src/hooks/useWhpApi.orphan-repair-wiring.test.tsx @@ -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['load']; + pages: ReturnType['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( + + + + + + + , + ); + }); + + 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: '

stranded

', 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(); + }); +}); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 33e0fc9..de87ba0 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -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);