fix(site-builder): final whole-branch review fixes

C1: HtmlBlock's PURIFY_CONFIG omitted 'style' from ALLOWED_ATTR, so the
toolbar colour picker added in this branch was silently deleted by
DOMPurify -- issue #2 was regressed, not fixed. Adds style/id plus table
tags, with tests pinning the markup path in both render and toHtml.

I3: PagesPanel's three confirmation states were not mutually exclusive;
cancelling delete revealed an unbidden reset prompt on a destructive action.

I5: orphan repair logged at console.warn, which the new console buffer
cannot see -- the reporter would never capture the most diagnostic signal
for the still-unreproduced drop bug. Also aligns useWhpApi's initial-load
failure handling with loadState's fallback.

I7: corrects comments (and the design spec) that asserted an orphan
"renders somewhere on the canvas", which a mid-plan audit disproved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 12:47:23 -07:00
co-authored by Claude Opus 5
parent 3dd6b54a35
commit 69e61ab4b2
12 changed files with 317 additions and 32 deletions
@@ -3,7 +3,7 @@ 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 { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider } from '../state/SiteDesignContext';
import { useWhpApi } from './useWhpApi';
import { WhpConfig } from '../types';
@@ -22,8 +22,10 @@ import { WhpConfig } from '../types';
* 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.
* reattached to ROOT and a `console.error` must fire, exactly like the
* page-switch path (I5 review: this was `console.warn`, which
* console-buffer.ts -- feeding the in-builder issue reporter -- does not
* capture).
*/
const deserializeMock = vi.fn();
vi.mock('@craftjs/core', () => ({
@@ -133,7 +135,7 @@ describe('useWhpApi load() repairs an orphaned node on the FIRST page before des
});
vi.stubGlobal('fetch', fetchMock);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const harness = render();
await act(async () => {
@@ -148,11 +150,69 @@ describe('useWhpApi load() repairs an orphaned node on the FIRST page before des
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');
// orphan happened to be absent for some unrelated reason. (React's own
// act()-environment warnings also go through console.error in this
// harness, so search all calls rather than assuming index 0.)
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
warnSpy.mockRestore();
errorSpy.mockRestore();
unmount();
});
});
/**
* I5 (review): `PageContext.loadState`'s deserialize failure path logs via
* `console.error` AND falls back to `EMPTY_CANVAS` so the user always ends
* up with a working (if blank) editor. `useWhpApi.load()`'s equivalent path
* used to be `console.warn` with NO fallback deserialize -- the initial
* load, which decides whether the user sees a working editor at all, both
* failed harder (silently leaving the Frame undeserialized) and reported
* quieter than every subsequent page switch. This pins the aligned
* behaviour.
*/
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
beforeEach(() => {
deserializeMock.mockClear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', 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: '{"ROOT":{"broken":true}}' },
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
deserializeMock.mockImplementationOnce(() => {
throw new Error('malformed state');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const harness = render();
await act(async () => {
await harness.get().load();
});
// First call (the broken state) threw; the second call is the fallback.
expect(deserializeMock).toHaveBeenCalledTimes(2);
expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);
expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));
errorSpy.mockRestore();
unmount();
});
});
+16 -3
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext';
import { usePages, EMPTY_CANVAS } from '../state/PageContext';
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml } from '../utils/html-export';
import { repairOrphanNodes } from '../utils/orphan-repair';
@@ -321,14 +321,27 @@ export function useWhpApi() {
? firstPage.craftState : JSON.stringify(firstPage.craftState);
const { state, repaired } = repairOrphanNodes(rawState);
if (repaired.length > 0) {
console.warn(
// I5: console-buffer.ts only patches console.error, and this
// reattach signal is the single most diagnostic clue for the
// still-unreproduced "elements drop off the canvas" report --
// it must reach the in-builder issue reporter's console buffer.
console.error(
`[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);
// I5: this is the initial load that decides whether the user
// sees a working editor at all -- align with `loadState`'s
// behaviour (console.error + a known-safe fallback) instead of
// warning quietly and leaving the Frame on whatever it last had.
console.error('Failed to load page state:', e);
try {
actions.deserialize(EMPTY_CANVAS);
} catch (_e2) {
// give up
}
}
}