From dcc7e27be4d1d79eb60c254e88b0776f880b9585 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 12:35:56 -0700 Subject: [PATCH] fix(site-builder): refuse Sitesmith page replace while editing a global master A page-scope Sitesmith replace with a master zone open loaded the AI page onto the zone canvas. On Done, its first section became the new master and the sync rewrote every instance on every page with it, orphaning all overrides. replaceCurrentPage now returns false inside a master zone and the apply path reports an error asking the user to click Done first. Refs #57 Co-Authored-By: Claude Opus 5.5 --- .../src/state/GlobalSectionsContext.test.tsx | 52 +++++++++++++++++++ craft/src/state/PageContext.tsx | 14 +++-- craft/src/utils/apply-ai-response.ts | 7 ++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/craft/src/state/GlobalSectionsContext.test.tsx b/craft/src/state/GlobalSectionsContext.test.tsx index c30c1b5..53de5ae 100644 --- a/craft/src/state/GlobalSectionsContext.test.tsx +++ b/craft/src/state/GlobalSectionsContext.test.tsx @@ -16,6 +16,7 @@ import { GuidedStyles } from '../panels/right/GuidedStyles'; import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from '../panels/sections/SectionLibraryModal'; import { Canvas } from '../editor/Canvas'; +import { useApplyAiResponse } from '../utils/apply-ai-response'; /** * #57 global sections against the REAL Craft editor + PageProvider + @@ -28,12 +29,14 @@ let pages: ReturnType; let globals: ReturnType; let extras: ReturnType; let guard: ReturnType; +let applyAi: ReturnType; const Probe: React.FC = () => { pages = usePages(); globals = useGlobalSections(); extras = useProjectExtras(); guard = useSaveGuard(); + applyAi = useApplyAiResponse(); return null; }; @@ -306,6 +309,55 @@ describe('#57 global sections', () => { expect(h!.query.history.canUndo()).toBe(false); }); + test('Sitesmith page-scope replace is refused while a master is open: no cross-page rewrite', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + let inst2 = ''; + h!.act(() => { inst2 = globals.insertInstance(libraryId)!; }); + const aiPage = { + name: 'AI page', + tree: { + type: { resolvedName: 'Container' }, props: {}, + nodes: [{ type: { resolvedName: 'Section' }, props: {}, nodes: [ + { type: { resolvedName: 'Heading' }, props: { text: 'AI takeover' } }, + ] }], + }, + } as any; + + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + const zoneBefore = h!.getSerialized(); + let r: any; + await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'page', pages: [aiPage] } as any); }); + await flush(); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/global section/i); + expect(pages.isEditingGlobal).toBe(true); + expect(h!.getSerialized()).toBe(zoneBefore); + expect(pages.pages.map((p) => p.name)).toEqual(['Home', 'About']); + + h!.act(() => { globals.finishEditingMaster(); }); + await flush(); + expect(lib()[libraryId].rev).toBe(1); + const p2 = live(); + expect(instanceRoots(p2)).toEqual([inst2]); + expect(JSON.stringify(p2)).not.toContain('AI takeover'); + expect(JSON.stringify(p2)).toContain('Welcome'); + const p1 = pages.pages[0].craftState!; + expect(p1).not.toContain('AI takeover'); + expect(instanceMeta((JSON.parse(p1) as SNodes)[sec])!.rev).toBe(1); + + // control: the same response on a normal page still replaces it + await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'page', pages: [aiPage] } as any); }); + await flush(); + expect(r.ok).toBe(true); + expect(h!.getSerialized()).toContain('AI takeover'); + }); + test('remove is refused while instances exist, allowed once none are left', () => { mount(); const sec = addPromo(); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 62462dc..4855637 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -83,7 +83,8 @@ interface PageContextValue { 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; + /** False (nothing changed) while a global-section master is on the canvas (#57). */ + replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => boolean; setHeader: (tree: SerializedTreeNode) => void; setFooter: (tree: SerializedTreeNode) => void; siteDesign: SiteDesign; @@ -267,7 +268,7 @@ const PageContext = createContext({ setPagesCraftState: () => {}, setActivePageIdDirect: () => {}, replaceAllPages: () => {}, - replaceCurrentPage: () => {}, + replaceCurrentPage: () => false, setHeader: () => {}, setFooter: () => {}, siteDesign: {} as SiteDesign, @@ -889,9 +890,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => * AI helper: replace the current page's tree. * Deserializes the new tree into the live Craft.js canvas and persists it. */ - const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => { - const craftState = treeToCraftState(page.tree); + const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }): boolean => { const currentId = activePageIdRef.current; + // #57: a master zone is not a page. Loading a page tree here would make + // its first section the new master on Done, and the sync would then + // rewrite every instance on every page with it. Refuse instead. + if (isGlobalZoneId(currentId)) return false; + const craftState = treeToCraftState(page.tree); if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, name: page.name, craftState })); } else if (currentId === FOOTER_ID) { @@ -902,6 +907,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => ); } loadState(craftState, EMPTY_CANVAS, { keepHistory: true }); + return true; }, [loadState]); /** diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 1b2b555..0fe283e 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -65,7 +65,12 @@ export function useApplyAiResponse() { } if (resp.scope === 'page') { - pages.replaceCurrentPage(resp.pages[0]); + if (!pages.replaceCurrentPage(resp.pages[0])) { + return { + ok: false, + message: 'You are editing a global section. Click Done, then ask again to replace the page.', + }; + } return { ok: true }; }