fix(pages): duplicatePage must save outgoing active canvas before teardown (avoid dropping live edits)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 07:47:44 -07:00
co-authored by Claude Opus 4.8
parent a698f014b0
commit 85dfe181aa
2 changed files with 118 additions and 25 deletions
@@ -240,6 +240,44 @@ describe('PageContext.movePage', () => {
unmount();
});
test('moving the current landing page down demotes it and promotes its neighbor', () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
// pages: [Home(index0, slug index), About]
const homeId = ctx!.pages[0].id;
const aboutId = ctx!.pages[1].id;
act(() => ctx!.movePage(homeId, 'down'));
// pages: [About, Home]
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
// Order changed and the landing invariant re-established: index 0
// (now About) gets slug 'index'; the moved page (now at index 1, Home)
// gets a real, non-'index' unique slug.
expect(ctx!.pages[0].id).toBe(aboutId);
expect(ctx!.pages[0].slug).toBe('index');
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
expect(demotedHome.slug).not.toBe('index');
expect(demotedHome.slug).toBe('home');
// Exactly one 'index' slug.
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
expect(indexPages).toHaveLength(1);
unmount();
});
});
describe('PageContext.setLandingPage', () => {
@@ -404,6 +442,50 @@ describe('PageContext.duplicatePage', () => {
unmount();
});
test('duplicating a non-active page does NOT drop the outgoing active page\'s live unsaved edits (regression lock)', async () => {
// Regression test for the Critical bug: duplicatePage(pageId) used to
// call saveCurrentState() ONLY when pageId === the active page, yet
// ALWAYS ended by tearing down the canvas via loadState() + switching
// activePageId to the copy. If the duplicated page was NOT the active
// one, the active page's live canvas edits were never serialized into
// its slot before that teardown -- silently discarded. This asserts the
// outgoing active page ('About') keeps its live-serialized craftState
// after duplicating a DIFFERENT page ('Home').
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {
ctx = usePages();
return null;
};
render(
<PageProvider>
<Consumer />
</PageProvider>,
);
act(() => ctx!.addPage('About', 'about'));
await flushTimers();
// pages: [Home, About]; About is active (addPage switches to it).
const homeId = ctx!.pages[0].id;
const aboutId = ctx!.pages[1].id;
expect(ctx!.activePageId).toBe(aboutId);
// Simulate the user having made live, unsaved edits to About (the
// active page) that have not yet been serialized into pages[] state.
const liveAboutEdit = '{"ROOT":{"live":"about-edit-not-yet-saved"}}';
serializeReturn = liveAboutEdit;
// Duplicate a DIFFERENT page (Home), not the active one (About).
act(() => ctx!.duplicatePage(homeId));
await flushTimers();
// The outgoing active page's live edits must have been persisted into
// its own slot before the canvas was torn down and switched away.
const aboutAfter = ctx!.pages.find((p) => p.id === aboutId)!;
expect(aboutAfter.craftState).toBe(liveAboutEdit);
unmount();
});
test('switches the canvas to the new copy (deserialize called with the copy craftState)', async () => {
let ctx: ReturnType<typeof usePages> | null = null;
const Consumer: React.FC = () => {