fix(global-sections): save/autosave/publish inside a master zone persist the live master

The zone's live master was neither in extras nor in the stored pages until
Done, so a save from inside the zone succeeded without it and cleared dirty;
closing the tab lost the edit (publish shipped stale instances). The save
payload now carries the master committed into the library and every stored
page/header/footer synced, computed exactly as Done would without touching the
editor (PageContext.globalZoneSaveView).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:23:46 -07:00
1 parent 37ed85bf42
commit 996c9e92ec
3 files changed
+137 -8

No files matched your search

+14 -6
View File
@@ -8,7 +8,7 @@ import { repairOrphanNodes } from '../utils/orphan-repair';
import { PageData } from '../types';
import { useSaveGuard } from '../state/SaveGuardContext';
import { useProjectExtras } from '../state/ProjectExtrasContext';
import { isGlobalZoneId } from '../utils/global-sections';
import { GLOBAL_SECTIONS_KEY, isGlobalZoneId } from '../utils/global-sections';
/**
* Per-state export memo. A craft-state string fully determines its exported
@@ -348,6 +348,7 @@ export function useWhpApi() {
setFooterCraftState,
setPagesCraftState,
setActivePageIdDirect,
globalZoneSaveView,
} = usePages();
const { design, updateDesign } = useSiteDesign();
const projectExtras = useProjectExtras();
@@ -381,19 +382,26 @@ export function useWhpApi() {
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
const forceOverwrite = !!opts?.forceOverwrite || forceOverwriteNextSave.current;
// #57: a master zone open on the canvas is not in extras or the stored
// 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;
const allExtras = projectExtras.all();
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
liveCraftState,
pages,
headerPage,
footerPage,
pages: zone ? zone.pages : pages,
headerPage: zone ? zone.headerPage : headerPage,
footerPage: zone ? zone.footerPage : footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
headCode: design.headCode,
design,
extras: projectExtras.all(),
extras: zone ? { ...allExtras, [GLOBAL_SECTIONS_KEY]: zone.library } : allExtras,
});
let result: ApiResult;
@@ -450,7 +458,7 @@ export function useWhpApi() {
} finally {
endSave(ticket, succeeded);
}
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras]);
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView]);
/**
* Save, one at a time. Every save (Save button, autosave, Keep mine, the
+85 -2
View File
@@ -10,7 +10,9 @@ import { NoticeProvider } from './NoticeContext';
import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from './SiteDesignContext';
import { buildNodeTree } from '../utils/craft-tree';
import { exportBodyHtml } from '../utils/html-export';
import { buildSavePayload } from '../hooks/useWhpApi';
import { buildSavePayload, useWhpApi } from '../hooks/useWhpApi';
import { EditorConfigProvider } from './EditorConfigContext';
import { vi } from 'vitest';
import { GLOBAL_SECTIONS_KEY, instanceMeta, type GlobalLibrary, type SNodes } from '../utils/global-sections';
import { GuidedStyles } from '../panels/right/GuidedStyles';
import { ContextMenu } from '../panels/context-menu/ContextMenu';
@@ -30,6 +32,22 @@ let globals: ReturnType<typeof useGlobalSections>;
let extras: ReturnType<typeof useProjectExtras>;
let guard: ReturnType<typeof useSaveGuard>;
let applyAi: ReturnType<typeof useApplyAiResponse>;
let api: ReturnType<typeof useWhpApi> | null = null;
const ApiProbe: React.FC = () => { api = useWhpApi(); return null; };
const WHP_CFG = { user: 'u', apiUrl: '/api/sb', csrfToken: 't', siteId: 1, siteDomain: 'example.com', siteName: 'S', backUrl: '/', isRoot: false };
/** Like mount(), plus a real useWhpApi (fetch mocked by the caller). */
function mountWithApi(): EditorHarness {
h = renderEditorHarness({
onRender: RenderNode,
wrap: (tree) => (
<EditorConfigProvider config={WHP_CFG}><SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}</SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider></EditorConfigProvider>
),
});
h.mountChild(<PageProvider><GlobalSectionsProvider><Probe /><ApiProbe /></GlobalSectionsProvider></PageProvider>);
h.act(() => { guard.setLoadState('loaded'); });
return h;
}
const Probe: React.FC = () => {
pages = usePages();
@@ -40,7 +58,7 @@ const Probe: React.FC = () => {
return null;
};
afterEach(() => { h?.unmount(); h = null; closeSectionLibrary(); document.body.innerHTML = ''; });
afterEach(() => { h?.unmount(); h = null; api = null; vi.unstubAllGlobals(); closeSectionLibrary(); document.body.innerHTML = ''; });
function mount(ui: React.ReactNode = null): EditorHarness {
h = renderEditorHarness({
@@ -283,6 +301,71 @@ describe('#57 global sections', () => {
expect(live()[byType(live(), 'Heading', sec)[0]].props.text).toBe('Via switch');
});
test('save / autosave while the master zone is open persists the LIVE master and the synced instances', 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();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.addPage('About', 'about'); });
await flush();
h!.act(() => { globals.insertInstance(libraryId); });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Live master'; }); });
h!.act(() => { guard.markDirty(); });
let res: any;
await h!.act(async () => { res = await api!.save(); });
expect(res.success).toBe(true);
const body = bodies[bodies.length - 1];
// The library in the payload holds the live master (rev bumped as Done would).
const savedMaster = body.extras.globalSections[libraryId];
expect(savedMaster.rev).toBe(2);
expect(JSON.stringify(savedMaster.nodes)).toContain('Live master');
// Every stored instance is synced in the payload, on both pages, and in the published html.
expect(body.pages_craft_state).toHaveLength(2);
for (const p of body.pages_craft_state) expect(p.craftState).toContain('Live master');
for (const p of body.pages) expect(p.html).toContain('Live master');
// The zone itself never lands in a page slot.
for (const p of body.pages_craft_state) expect(JSON.parse(p.craftState).ROOT).toBeTruthy();
expect(guard.isDirty()).toBe(false);
// Still in the zone; the editor itself was not rewritten under the user.
expect(pages.isEditingGlobal).toBe(true);
// Done afterwards commits the same master: rev 2, instance on the page shows it.
h!.act(() => { globals.finishEditingMaster(); });
await flush();
expect(lib()[libraryId].rev).toBe(2);
expect(JSON.stringify(live())).toContain('Live master');
});
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) => {
const action = /action=(\w+)/.exec(String(url))![1];
calls.push({ action, body: init?.body ? JSON.parse(init.body) : null });
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, rev: 'r' + calls.length }), text: async () => '' } as any;
}));
mountWithApi();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Publish me'; }); });
await h!.act(async () => { await api!.publish(); });
expect(calls.map((c) => c.action)).toEqual(['save', 'publish']);
expect(calls[0].body.pages[0].html).toContain('Publish me');
expect(JSON.stringify(calls[0].body.extras.globalSections[libraryId].nodes)).toContain('Publish me');
});
test('detach: plain copy (one undo step) that later master edits never touch', async () => {
mount();
const sec = addPromo();
+38
View File
@@ -13,6 +13,13 @@ import {
masterFromZoneState, masterZoneState, syncInstances,
} from '../utils/global-sections';
export interface GlobalZoneSaveView {
library: GlobalLibrary;
pages: PageData[];
headerPage: PageData;
footerPage: PageData;
}
interface PageContextValue {
pages: PageData[];
headerPage: PageData;
@@ -35,6 +42,14 @@ interface PageContextValue {
editGlobal: (libraryId: string) => boolean;
/** Leave the master zone back to where `editGlobal` was called from. */
finishGlobal: () => GlobalCommitResult;
/**
* #57: what Save / autosave / Publish must persist while a master zone is
* open: the live master (`liveState`) committed into the library and every
* stored page/header/footer synced, exactly as Done would -- computed
* WITHOUT touching editor state (Done still commits for real). Null when no
* zone is open, or the master is unchanged or empty.
*/
globalZoneSaveView: (liveState: string) => GlobalZoneSaveView | null;
switchPage: (pageId: string) => void;
editHeader: () => void;
editFooter: () => void;
@@ -252,6 +267,7 @@ const PageContext = createContext<PageContextValue>({
globalEditingId: null,
editGlobal: () => false,
finishGlobal: () => NO_COMMIT,
globalZoneSaveView: () => null,
switchPage: () => {},
editHeader: () => {},
editFooter: () => {},
@@ -452,6 +468,27 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
return { transform, result: { changed: true, empty: false, dropped: res.dropped } };
}, [extras, query]);
const globalZoneSaveView = useCallback((liveState: string): GlobalZoneSaveView | null => {
const libraryId = libraryIdOfZone(activePageIdRef.current);
const lib = extras.get<GlobalLibrary>(GLOBAL_SECTIONS_KEY, {});
const prev = libraryId ? lib[libraryId] : undefined;
if (!libraryId || !prev) return null;
const res = masterFromZoneState(liveState, prev, Date.now(), zoneBaselineRef.current);
if (!res || !res.changed) return null;
const nextLib: GlobalLibrary = { ...lib, [libraryId]: res.master };
const sync = (p: PageData): PageData => {
if (!p.craftState) return p;
const n = syncInstances(p.craftState, nextLib, lib);
return n === p.craftState ? p : { ...p, craftState: n };
};
return {
library: nextLib,
pages: pagesRef.current.map(sync),
headerPage: sync(headerPageRef.current),
footerPage: sync(footerPageRef.current),
};
}, [extras]);
/** Save whatever is on the current Frame back to the right state slot */
const saveCurrentState = useCallback(() => {
const currentId = activePageIdRef.current;
@@ -989,6 +1026,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
globalEditingId,
editGlobal,
finishGlobal,
globalZoneSaveView,
switchPage,
editHeader,
editFooter,