diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 795de9f..4a12374 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -349,6 +349,7 @@ export function useWhpApi() { setPagesCraftState, setActivePageIdDirect, globalZoneSaveView, + pendingCanvasState, } = usePages(); const { design, updateDesign } = useSiteDesign(); const projectExtras = useProjectExtras(); @@ -376,7 +377,10 @@ export function useWhpApi() { // Serialize whatever is currently on the live canvas (a page, the // header, or the footer — depending on activePageId/isEditingHeader/ // isEditingFooter). - const liveCraftState = query.serialize(); + // Mid-switch (deferred deserialize pending) the mounted canvas is the + // PREVIOUS page; persist what the active slot is about to show instead. + const pending = pendingCanvasState?.() ?? null; + const liveCraftState = pending ?? query.serialize(); const baseRev = getRev(); const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current; @@ -386,7 +390,8 @@ export function useWhpApi() { // pages until Done. Save it as Done would (live master in the library, // every instance synced), or the save "succeeds" without the edits and // clears dirty -- and closing the tab loses them. - const zone = isGlobalZoneId(activePageId) ? globalZoneSaveView(liveCraftState) : null; + // (A zone still loading is unedited: nothing to commit.) + const zone = isGlobalZoneId(activePageId) && !pending ? globalZoneSaveView(liveCraftState) : null; const allExtras = projectExtras.all(); const payload = buildSavePayload({ @@ -458,7 +463,7 @@ export function useWhpApi() { } finally { endSave(ticket, succeeded); } - }, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView]); + }, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView, pendingCanvasState]); /** * Save, one at a time. Every save (Save button, autosave, Keep mine, the diff --git a/craft/src/state/GlobalSectionsContext.test.tsx b/craft/src/state/GlobalSectionsContext.test.tsx index 1d7d85b..25f5d39 100644 --- a/craft/src/state/GlobalSectionsContext.test.tsx +++ b/craft/src/state/GlobalSectionsContext.test.tsx @@ -353,6 +353,33 @@ describe('#57 global sections', () => { expect(JSON.stringify(live())).toContain('Live master'); }); + test('minor: a save between a page switch and its deferred deserialize writes the TARGET page, not the old canvas', async () => { + const bodies: any[] = []; + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { + if (String(url).includes('action=save')) bodies.push(JSON.parse(init.body)); + return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, rev: 'r' + bodies.length }), text: async () => '' } as any; + })); + mountWithApi(); + addPromo(); // home: Welcome / Body copy / Go + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + // Switch back home and save BEFORE the deferred deserialize has run. + let res: any; + h!.act(() => { pages.switchPage('home'); }); // re-rendered; deserialize still pending (setTimeout 0) + expect(pages.activePageId).toBe('home'); + expect(JSON.stringify(live())).not.toContain('Welcome'); + await h!.act(async () => { res = await api!.save(); }); + expect(res.success).toBe(true); + const body = bodies[bodies.length - 1]; + const home = body.pages_craft_state.find((p: any) => p.id === 'home'); + const about = body.pages_craft_state.find((p: any) => p.slug === 'about'); + expect(home.craftState).toContain('Welcome'); + expect(about.craftState ?? '').not.toContain('Welcome'); + expect(body.pages[0].html).toContain('Welcome'); + await flush(); + expect(JSON.stringify(live())).toContain('Welcome'); + }); + test('publish from inside the master zone saves the live master first', async () => { const calls: Array<{ action: string; body: any }> = []; vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => { diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 8bec861..abc9276 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -50,6 +50,13 @@ interface PageContextValue { * zone is open, or the master is unchanged or empty. */ globalZoneSaveView: (liveState: string) => GlobalZoneSaveView | null; + /** + * The state a page/zone switch is about to put on the canvas, while its + * deferred deserialize has not run yet (else null). A save in that window + * must persist THIS, not the old canvas still mounted -- which would land + * in the new page's slot. + */ + pendingCanvasState: () => string | null; switchPage: (pageId: string) => void; editHeader: () => void; editFooter: () => void; @@ -269,6 +276,7 @@ const PageContext = createContext({ editGlobal: () => false, finishGlobal: () => NO_COMMIT, globalZoneSaveView: () => null, + pendingCanvasState: () => null, switchPage: () => {}, editHeader: () => {}, editFooter: () => {}, @@ -422,6 +430,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => const isEditingGlobal = globalEditingId !== null; /** Where Done in a master zone returns to (#57). */ const returnToRef = useRef(null); + /** A deferred loadState deserialize not yet run: what the canvas is about to hold. */ + const pendingLoadRef = useRef<{ seq: number; state: string | null }>({ seq: 0, state: null }); /** The master zone as first deserialized: the "unchanged" reference. */ const zoneBaselineRef = useRef(null); @@ -490,6 +500,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => }; }, [extras]); + const pendingCanvasState = useCallback((): string | null => pendingLoadRef.current.state, []); + /** Save whatever is on the current Frame back to the right state slot */ const saveCurrentState = useCallback(() => { const currentId = activePageIdRef.current; @@ -533,7 +545,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => // into this slot. Only an in-place replacement of the SAME slot (the // AI rewriting the current page) keeps history, so it stays undoable. const clearHistory = !opts?.keepHistory; + const seq = ++pendingLoadRef.current.seq; + pendingLoadRef.current.state = opts?.applyAfter ?? (craftState || fallback); setTimeout(() => { + if (pendingLoadRef.current.seq === seq) pendingLoadRef.current.state = null; const source = craftState || fallback; const { state, repaired } = repairOrphanNodes(source); if (repaired.length > 0) { @@ -1033,6 +1048,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => editGlobal, finishGlobal, globalZoneSaveView, + pendingCanvasState, switchPage, editHeader, editFooter,