From 25e674badd83a79158dae4d193bda4d231f1bc88 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 12 Jul 2026 17:29:32 -0700 Subject: [PATCH] Fix I-1: landing-page edits lost when activePageId goes dangling buildSavePayload matched the active page by page.id === activePageId, but activePageId defaults to the hardcoded 'home' and load() never updated it. Deleting the original Home page (its replacement gets id page_), reloading, editing, and saving would then match no page -- the live edit only reached the legacy top-level craft_state, while the authoritative pages[]/pages_craft_state[0] for index.html fell back to stale state. Belt-and-suspenders fix: - buildSavePayload now falls back to pages[0] as the effective active page when a real page is active but activePageId matches nothing. - useWhpApi's load() now points activePageId at the freshly restored first page via a new PageContext setActivePageIdDirect setter (bookkeeping only -- no re-serialize/deserialize), so activePageId stays valid after every load. Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/src/hooks/useWhpApi.save.test.ts | 29 +++++++++++++++++++++++ craft/src/hooks/useWhpApi.ts | 32 +++++++++++++++++++++++--- craft/src/state/PageContext.tsx | 23 ++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/craft/src/hooks/useWhpApi.save.test.ts b/craft/src/hooks/useWhpApi.save.test.ts index 91cb1df..96ef8ef 100644 --- a/craft/src/hooks/useWhpApi.save.test.ts +++ b/craft/src/hooks/useWhpApi.save.test.ts @@ -87,4 +87,33 @@ describe('buildSavePayload', () => { expect(payload.header_craft_state).toBe('STALE_HEADER'); expect(payload.footer_craft_state).toBe('STALE_FOOTER'); }); + + test('I-1: activePageId matches no page (dangling, e.g. original Home deleted then reload reset it to "home") -- live serialize still lands in pages_craft_state[0] / index.html, not lost', () => { + // pages[0] has id 'page_x' (the replacement landing page after the + // original 'home' was deleted); activePageId is stuck at the stale + // default 'home', which matches no entry in `pages`. + const pageX: PageData = { id: 'page_x', name: 'Home', slug: 'index', craftState: 'STORED_X' }; + const p2: PageData = { id: 'p2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT_2' }; + + const payload = buildSavePayload({ + siteId: 1, + siteName: 'Test Site', + liveCraftState: 'LIVE_EDIT', + pages: [pageX, p2], + headerPage, + footerPage, + activePageId: 'home', + isEditingHeader: false, + isEditingFooter: false, + }); + + // The live edit must land in pages_craft_state[0] (index.html slot), + // not be silently dropped to only the legacy top-level fields. + expect(payload.pages_craft_state[0].craftState).toBe('LIVE_EDIT'); + expect(payload.pages[0].filename).toBe('index.html'); + // Top-level fields (legacy) should also reflect the live edit. + expect(payload.craft_state).toBe('LIVE_EDIT'); + // The other page is untouched. + expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2'); + }); }); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index c76bf45..2319c4d 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -49,6 +49,18 @@ export function buildSavePayload(input: BuildSavePayloadInput) { const isPageActive = !isEditingHeader && !isEditingFooter; + // I-1 (data-loss): `activePageId` can go dangling -- e.g. the original + // Home page (id 'home') is deleted, its replacement gets a fresh id + // (`page_`), and a reload re-initializes `activePageId` back to the + // hardcoded default `'home'` (see PageContext's `useState('home')`) before + // `load()` has a chance to point it at the actually-restored page. If a + // real page IS active but matches no entry in `pages`, treat `pages[0]` + // (the landing page) as the active one so the live canvas serialization + // still reaches the index.html page slot / `pages_craft_state[0]` instead + // of only the legacy top-level `craft_state`/`html` fields. + const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1; + const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id; + // Fresh header/footer state: the live canvas wins when that zone is the // one currently being edited; otherwise fall back to the last-committed // stored state (updated on zone switch by PageContext's saveCurrentState). @@ -115,7 +127,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) { const filename = i === 0 ? 'index.html' : page.slug + '.html'; let pageHtml = ''; - if (isPageActive && page.id === activePageId) { + if (isPageActive && page.id === effectiveActivePageId) { // Active page: use the current canvas HTML (already exported above) pageHtml = currentHtml; } else if (page.craftState) { @@ -146,7 +158,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) { // reload the editor's clean-URL routing (.htaccess rewrite of /name → // name.html) lines up with the file we just wrote (index.html). slug: i === 0 ? 'index' : page.slug, - craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null), + craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null), })); return { @@ -177,6 +189,7 @@ export function useWhpApi() { setHeaderCraftState, setFooterCraftState, setPagesCraftState, + setActivePageIdDirect, } = usePages(); const save = useCallback(async () => { @@ -271,10 +284,23 @@ export function useWhpApi() { console.warn('Failed to load page state:', e); } } + + // I-1 (data-loss): point activePageId at the page we just loaded + // into the canvas. Without this, activePageId stays at whatever it + // was initialized to (the hardcoded default 'home'), which goes + // dangling the moment the original Home page has been deleted and + // replaced (its replacement gets a fresh `page_` id) -- the next + // edit+save would then only reach the legacy top-level fields + // instead of the actual page slot. Only do this when a real page is + // being loaded, i.e. we're not currently mid-edit of the header/ + // footer zone (switching zones is handled separately by switchPage). + if (!isEditingHeader && !isEditingFooter) { + setActivePageIdDirect(firstPage.id); + } } } return data; - }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]); + }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]); const uploadAsset = useCallback( async (file: File) => { diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 3aeee48..0419674 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -21,6 +21,17 @@ interface PageContextValue { setHeaderCraftState: (craftState: string) => void; setFooterCraftState: (craftState: string) => void; setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void; + /** + * Bookkeeping-only: point `activePageId` at an already-loaded page without + * re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's + * `load()` -- has already put the right state on the canvas itself). Used + * to fix I-1: `activePageId` defaults to the hardcoded `'home'` and + * `load()` never updated it, so after the original Home page was deleted + * (its replacement gets a fresh `page_` id) and the app reloaded, + * `activePageId` pointed at nothing in `pages`, and a subsequent edit+save + * only reached the legacy top-level fields. + */ + setActivePageIdDirect: (pageId: string) => void; /** AI helpers — replace entire site or page with a new tree */ replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void; replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void; @@ -149,6 +160,7 @@ const PageContext = createContext({ setHeaderCraftState: () => {}, setFooterCraftState: () => {}, setPagesCraftState: () => {}, + setActivePageIdDirect: () => {}, replaceAllPages: () => {}, replaceCurrentPage: () => {}, setHeader: () => {}, @@ -385,6 +397,16 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => setFooterPage((prev) => ({ ...prev, craftState })); }, []); + /** + * Bookkeeping-only setter for `activePageId` -- see the doc comment on + * `PageContextValue.setActivePageIdDirect`. Does NOT serialize/deserialize + * the canvas; callers that need that should use `switchPage` instead. + */ + const setActivePageIdDirect = useCallback((pageId: string) => { + setActivePageId(pageId); + activePageIdRef.current = pageId; + }, []); + /** Allow external code (e.g., load from API) to restore pages with craft states */ const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => { setPages(pagesData.map((p, i) => ({ @@ -492,6 +514,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => setHeaderCraftState, setFooterCraftState, setPagesCraftState, + setActivePageIdDirect, replaceAllPages, replaceCurrentPage, setHeader,