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
@@ -22,6 +22,41 @@ describe('purifyHtml', () => {
});
});
describe('purifyHtml markup path (C1 review finding)', () => {
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
expect(out).toBe('<p style="color: #ff0000">red text</p>');
});
test('an id attribute survives sanitization (anchor targets)', () => {
const out = purifyHtml('<a href="#section" id="section">link</a>');
expect(out).toContain('id="section"');
});
test('a pasted table survives sanitization', () => {
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(purifyHtml(input)).toBe(input);
});
test('script tags still do not survive alongside a style attribute', () => {
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
expect(out).not.toContain('<script');
expect(out).toContain('style="color:#ff0000"');
});
test('on* handlers still do not survive on an element that also carries style', () => {
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
expect(out).not.toContain('onclick');
expect(out).toContain('style="color:#ff0000"');
});
test('javascript: URLs still do not survive on an element that also carries style', () => {
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
expect(out).not.toContain('javascript:');
expect(out).toContain('style="color:#ff0000"');
});
});
describe('purifyHtml iframe sandboxing (M-6)', () => {
test('forces a restrictive sandbox attribute onto every iframe', () => {
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
@@ -33,3 +33,16 @@ test('toHtml never emits the style prop (the other half of the render/export con
expect(out.html).not.toContain('background');
expect(out.html).not.toContain('40px');
});
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
expect(html).toBe('<p style="color: #ff0000">red text</p>');
});
test('a table inside `code` reaches exported output', () => {
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
const { html } = toHtml({ code }, '');
expect(html).toBe(code);
});
});
+8 -1
View File
@@ -19,10 +19,17 @@ const PURIFY_CONFIG = {
'blockquote','code','pre',
'img','figure','figcaption',
'iframe',
// Tables: pasted content commonly includes these; dropping them
// silently ate customer-pasted tables (see C1 review finding).
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
],
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
// allowlist rather than extending it, so anything the product needs
// (style, id, ...) must be listed explicitly here even though DOMPurify
// would allow it by default.
ALLOWED_ATTR: [
'href','src','alt','title','target','rel',
'width','height','class',
'width','height','class','id','style',
'allowfullscreen','allow','frameborder',
'sandbox','referrerpolicy',
],
@@ -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
}
}
}
@@ -0,0 +1,119 @@
import { describe, test, expect } from 'vitest';
import React from 'react';
import { renderEditorHarness } from '../../test-utils/editorHarness';
import { PageProvider, usePages } from '../../state/PageContext';
import { PagesPanel } from './PagesPanel';
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
return btn;
}
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
const btn = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === text,
) as HTMLButtonElement | undefined;
if (!btn) throw new Error(`No button found with text "${text}"`);
return btn;
}
/**
* I3 (review): `deleteConfirmId`, `resetConfirmId` and `editingId` are three
* independent bits of local state, but the row rendering treats them as
* mutually exclusive (edit -> delete -> reset -> normal, first match wins).
* Before this fix, none of the three setters cleared the other two, so if
* both `resetConfirmId` and `deleteConfirmId` were ever set to the same page
* id, the delete-confirm view would win (it's checked first) and cancelling
* it would fall through to reveal the reset-confirm view "unbidden" -- a
* destructive prompt the user never asked for.
*
* This is reachable any time two arm-clicks land in the same render pass
* (e.g. a fast double click / synthetic dual dispatch before React commits
* the first click's re-render) -- reproduced below by issuing both clicks
* inside a single `act()` batch, which is exactly what removes the re-render
* that would otherwise make the second button disappear before it can be
* clicked.
*/
describe('PagesPanel confirmation-state isolation (I3 review finding)', () => {
async function setupTwoPages() {
let ctx: ReturnType<typeof usePages> | null = null;
const Probe: React.FC = () => { ctx = usePages(); return null; };
const harness = renderEditorHarness();
harness.mountChild(
<PageProvider>
<Probe />
<PagesPanel />
</PageProvider>,
);
harness.act(() => { ctx!.addPage('About', 'about'); });
await harness.act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
return harness;
}
test('arming Reset then Delete for the same page in one batch shows Delete (render precedence), and cancelling Delete does NOT reveal a leftover Reset prompt', async () => {
const harness = await setupTwoPages();
// Both arm-clicks land before any re-render commits.
harness.act(() => {
clickByLabel(harness.container, 'Reset About to blank').click();
clickByLabel(harness.container, 'Delete About').click();
});
expect(harness.container.textContent).toContain('Delete "About"?');
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
// The fix: arming Delete clears any pending Reset confirmation for the
// same page, so cancelling Delete returns to the normal row, not a
// surprise Reset prompt.
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
expect(harness.container.textContent).not.toContain('Delete "About"?');
harness.unmount();
});
test('arming Delete then Reset for the same page in one batch shows Reset, and cancelling Reset does NOT reveal a leftover Delete prompt', async () => {
const harness = await setupTwoPages();
harness.act(() => {
clickByLabel(harness.container, 'Delete About').click();
clickByLabel(harness.container, 'Reset About to blank').click();
});
expect(harness.container.textContent).toContain('Clear every element from "About"?');
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
expect(harness.container.textContent).not.toContain('Delete "About"?');
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
harness.unmount();
});
test('starting a Rename while a Reset confirmation is pending for the same page clears the pending Reset', async () => {
const harness = await setupTwoPages();
// Arm Reset, then (same batch) start editing the same row -- edit wins
// in render precedence, but the fix also clears resetConfirmId so
// cancelling the rename doesn't fall through to a stale Reset prompt.
harness.act(() => {
clickByLabel(harness.container, 'Reset About to blank').click();
clickByLabel(harness.container, 'Rename About').click();
});
const nameInput = harness.container.querySelector('input.control-input') as HTMLInputElement | null;
expect(nameInput).toBeTruthy();
expect(nameInput!.value).toBe('About');
harness.act(() => {
clickByText(harness.container, 'Cancel').click();
});
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
harness.unmount();
});
});
+9 -2
View File
@@ -77,6 +77,7 @@ export const PagesPanel: React.FC = () => {
setEditName(page.name);
setEditSlug(page.slug);
setDeleteConfirmId(null);
setResetConfirmId(null);
};
const autoSlug = (name: string): string => {
@@ -515,7 +516,10 @@ export const PagesPanel: React.FC = () => {
<i className="fa fa-pencil" aria-hidden="true" />
</button>
<button
onClick={() => setResetConfirmId(page.id)}
onClick={() => {
setResetConfirmId(page.id);
setDeleteConfirmId(null);
}}
data-tooltip="Reset to blank"
aria-label={`Reset ${page.name} to blank`}
style={pageActionBtnStyle({ danger: true })}
@@ -524,7 +528,10 @@ export const PagesPanel: React.FC = () => {
</button>
{pages.length > 1 && !isLanding && (
<button
onClick={() => setDeleteConfirmId(page.id)}
onClick={() => {
setDeleteConfirmId(page.id);
setResetConfirmId(null);
}}
data-tooltip="Delete"
aria-label={`Delete ${page.name}`}
style={pageActionBtnStyle({ danger: true })}
+1 -1
View File
@@ -165,7 +165,7 @@ export const GuidedStyles: React.FC = () => {
{/* HTML -- code editor only */}
{isHtml && <HtmlStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */}
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* FALLBACK: Anything not matched above */}
@@ -122,7 +122,12 @@ describe('loadState (via switchPage) repairs an orphaned node before handing it
),
);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// I5 (review): the orphan-repair log must be console.error, not
// console.warn -- console-buffer.ts (feeding the in-builder issue
// reporter) only patches console.error, and this reattach signal is the
// single most diagnostic clue for the still-unreproduced "elements drop
// off the canvas" report.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
deserializeMock.mockClear();
// The real switch-into-a-stored-page path.
@@ -137,11 +142,12 @@ describe('loadState (via switchPage) repairs an orphaned node before handing it
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();
});
});
+18 -7
View File
@@ -371,19 +371,30 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
/** 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. */
* Every state goes through `repairOrphanNodes` first: a node present in
* the serialized state but not reachable from ROOT via `nodes`/
* `linkedNodes` is never instantiated by Craft.js's `<Frame>` at all --
* it doesn't render, so it isn't merely unselectable, it's invisible and
* otherwise unrecoverable. Reattaching it to the end of ROOT makes it an
* ordinary child the user can see, select and delete. Cheap (single JSON
* round-trip) and a no-op -- returning the identical string -- for the
* overwhelmingly common healthy case.
*
* Note this orphan-node repair is a different mechanism from the
* originally-reported symptom (a *visible* element on the canvas that
* can't be selected or deleted) -- that report is still unreproduced;
* see `orphan-repair.ts` for detail. */
const loadState = useCallback(
(craftState: string | null, fallback: string) => {
setTimeout(() => {
const source = craftState || fallback;
const { state, repaired } = repairOrphanNodes(source);
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(', '),
);
+17 -5
View File
@@ -3,11 +3,23 @@
*
* A node that exists in `SerializedNodes` but appears in no parent's `nodes`
* or `linkedNodes` list is invisible to the Layers tree AND to Craft's own
* selection machinery -- it renders somewhere on the canvas but can't be
* selected or deleted, which is exactly the "dropped outside the page"
* report. Reachability is computed from the PARENT'S child lists, not from
* each node's own `parent` pointer: a stale `parent: 'ROOT'` on a node ROOT
* never lists is precisely the broken case we're looking for.
* selection machinery -- but not because it "renders but can't be selected".
* Craft.js's `<Frame>` only instantiates nodes it can actually walk to via
* `data.nodes`/`linkedNodes` starting from ROOT, so an unreachable node is
* never rendered at all: it doesn't appear on the canvas, it's just inert
* data sitting in the serialized state. Reachability is computed from the
* PARENT'S child lists, not from each node's own `parent` pointer: a stale
* `parent: 'ROOT'` on a node ROOT never lists is precisely the broken case
* we're looking for.
*
* This is a DIFFERENT mechanism from the originally-reported symptom -- a
* *visible* element on the canvas that can't be selected or deleted. That
* symptom requires the node to be both rendered AND excluded from Craft's
* selection/interaction machinery, which is not what an unreachable-from-
* ROOT node produces (it isn't rendered at all). This repair fixes the
* "node is present in state but invisible/unrecoverable" case; the
* originally-reported "visible but unselectable" case is still
* unreproduced and is presumed to be a different bug.
*
* Pure functions over serialized state -- no React, no Craft instance.
*/
@@ -46,6 +46,8 @@ The mechanism is deliberately left open pending a live reproduction. Two structu
**The plan's first task is reproduction, and its acceptance criterion is a written description of the actual mechanism.** No fix is written before that.
**Status (2026-08-08, final review pass): NOT implemented.** The reproduction task did not land during this branch — the drop-time mechanism described above (stale drop indicator vs. wrapper-`<div>` drop target, or something else) was never confirmed against a live editor session, so no prevention fix was written, per the acceptance criterion above. What shipped instead is 1b (repair) and 1c (recovery), plus the in-builder issue reporter from Item 5, whose plan was to capture a real repro of this specific bug from customers going forward. A code-review pass on this branch found that `useWhpApi.ts` and `PageContext.tsx`'s orphan-repair log lines used `console.warn`, which the reporter's console buffer does not capture (it patches `console.error` only) — fixed to `console.error` so a future in-the-wild repro of this exact symptom is actually captured. Reproducing the drop-time mechanism and writing the 1a prevention fix remains open work.
### 1b. Repair (load time)
`PageContext`'s deserialization path runs an orphan sweep before handing state to Craft: