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:
1 parent
6d22aabdc0
commit
a2d18b7f4b
3 files changed
+51
-3
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user