From a406ff8548d2586d5ccc9ed515c828e65f065d27 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 10:00:11 -0700 Subject: [PATCH] fix(site-builder): fail load on unreadable page state; disable editing controls until loaded Refs #28 --- craft/src/App.tsx | 20 +++++------ craft/src/editor/LoadGate.save-guard.test.tsx | 28 +++++++++++++++ craft/src/editor/LoadGate.tsx | 13 +++++-- .../useWhpApi.orphan-repair-wiring.test.tsx | 20 ++++++----- craft/src/hooks/useWhpApi.ts | 35 +++++++++++++------ .../src/panels/sitesmith/SitesmithButton.tsx | 5 +-- craft/src/panels/topbar/TemplateModal.tsx | 10 +++--- craft/src/panels/topbar/TopBar.tsx | 18 +++++----- .../src/panels/topbar/TopBarOverflowMenu.tsx | 7 ++-- craft/src/styles/editor.css | 2 +- 10 files changed, 110 insertions(+), 48 deletions(-) diff --git a/craft/src/App.tsx b/craft/src/App.tsx index 8557289..a3b3b38 100644 --- a/craft/src/App.tsx +++ b/craft/src/App.tsx @@ -25,16 +25,16 @@ export const App: React.FC = ({ whpConfig }) => { return ( - - - - - - - - - - + + + + + + + + + + ); diff --git a/craft/src/editor/LoadGate.save-guard.test.tsx b/craft/src/editor/LoadGate.save-guard.test.tsx index 1713764..7d0d127 100644 --- a/craft/src/editor/LoadGate.save-guard.test.tsx +++ b/craft/src/editor/LoadGate.save-guard.test.tsx @@ -82,6 +82,7 @@ beforeEach(() => { vi.stubGlobal('fetch', fetchMock); vi.spyOn(console, 'error').mockImplementation(() => {}); undoMock.mockClear(); + deserializeMock.mockReset(); }); afterEach(() => { @@ -124,6 +125,33 @@ describe('load gate', () => { mount(); // so afterEach has something to unmount }); + test('a page state that fails to deserialize fails the load, names the problem, and never saves', async () => { + fetchMock.mockResolvedValue(jsonResp({ + success: true, + rev: 'r1', + project: { + pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }], + }, + })); + deserializeMock.mockImplementationOnce(() => { throw new Error('malformed'); }); + mount(); + await flush(); + + expect(q('[data-testid="load-gate"]')!.textContent).toContain("We couldn't read part of your saved site."); + expect(q('[data-testid="load-retry"]')).not.toBeNull(); + await act(async () => { vi.advanceTimersByTime(30000); }); + expect(saveCalls()).toHaveLength(0); + }); + + test('canvas-changing top-bar controls are disabled until the site has loaded', async () => { + fetchMock.mockRejectedValue(new Error('down')); + mount(); + await flush(); + for (const label of ['Undo', 'Redo', 'Templates', 'Custom Head Code']) { + expect((q(`button[aria-label="${label}"]`) as HTMLButtonElement).disabled).toBe(true); + } + }); + test('shows a loading overlay while the load is in flight', async () => { fetchMock.mockReturnValue(new Promise(() => {})); mount(); diff --git a/craft/src/editor/LoadGate.tsx b/craft/src/editor/LoadGate.tsx index 70e69ba..c0cc861 100644 --- a/craft/src/editor/LoadGate.tsx +++ b/craft/src/editor/LoadGate.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useEditorConfig } from '../state/EditorConfigContext'; import { useSaveGuard } from '../state/SaveGuardContext'; -import { useWhpApi } from '../hooks/useWhpApi'; +import { useWhpApi, UnreadableSiteError } from '../hooks/useWhpApi'; /** * Owns the initial project load (WHP mode only) and blocks the whole editor @@ -13,13 +13,16 @@ export const LoadGate: React.FC = () => { const { loadState, setLoadState } = useSaveGuard(); const { load } = useWhpApi(); const startedRef = useRef(false); + const [unreadable, setUnreadable] = useState(false); const runLoad = useCallback(() => { + setUnreadable(false); setLoadState('loading'); load() .then(() => setLoadState('loaded')) .catch((e) => { console.error('Failed to load project from WHP API:', e); + setUnreadable(e instanceof UnreadableSiteError); setLoadState('failed'); }); }, [load, setLoadState]); @@ -38,7 +41,11 @@ export const LoadGate: React.FC = () => {