fix(save): a save during a page/zone switch persists the target page, not the old canvas

PageContext.loadState deserializes on setTimeout(0); a save landing in that
window serialized the previous page's still-mounted canvas into the new active
page's slot. loadState now records the pending state and sendSave uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:33:58 -07:00
1 parent 6d22aabdc0
commit a2d18b7f4b
3 files changed
+51 -3

No files matched your search

+8 -3
View File
@@ -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
@@ -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) => {
+16
View File
@@ -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<PageContextValue>({
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<string | null>(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<string | null>(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,