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
+36 -25
View File
@@ -268,24 +268,28 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
* shared "fix the invariant up after reordering" step, and directly
* unit-testable without mounting `PageProvider`.
*
* Assumes at most one page enters with slug `'index'` (true for any array
* Normally at most one page enters with slug `'index'` (true for any array
* that already satisfied the invariant before the reorder that produced this
* input) -- exactly the case both callers hand it.
* input) -- exactly the case both callers hand it. Defensively, though, a
* STRAY second page with slug `'index'` at index > 0 (e.g. from legacy
* loaded data that predates this invariant) is also demoted rather than left
* as a duplicate -- see the running `usedSlugs` accumulation below.
*/
export function applyLandingInvariant(pages: PageData[]): PageData[] {
if (pages.length === 0) return pages;
// Slugs that are FIXED and must not be collided into: 'index' (reserved
// for whoever ends up at index 0) plus every non-landing page's existing
// slug except the demoted page's (it currently holds 'index' and is about
// to be given a new one). Computed upfront, over the WHOLE array, so the
// demoted page's new slug is checked against every other page regardless
// of array order -- checking only "slugs seen so far" while walking the
// array would miss a collision against a page that appears LATER in the
// list than the demoted one.
const fixedSlugs: string[] = ['index'];
// Slugs that must not be collided into: 'index' (reserved for whoever
// ends up at index 0) plus every non-landing page's existing slug except
// any demoted page's (it currently holds 'index' and is about to be given
// a new one). Computed upfront, over the WHOLE array, so a demoted page's
// new slug is checked against every other page regardless of array order
// -- checking only "slugs seen so far" while walking the array would miss
// a collision against a page that appears LATER in the list than the
// demoted one. Mutated (pushed to) as pages are demoted below so that two
// demoted pages in the same pass can't collide with EACH OTHER either.
const usedSlugs: string[] = ['index'];
for (let i = 1; i < pages.length; i++) {
if (pages[i].slug !== 'index') fixedSlugs.push(pages[i].slug);
if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug);
}
return pages.map((page, i) => {
@@ -293,8 +297,11 @@ export function applyLandingInvariant(pages: PageData[]): PageData[] {
return page.slug === 'index' ? page : { ...page, slug: 'index' };
}
if (page.slug === 'index') {
// Demoted landing page -- give it a real, unique slug of its own.
return { ...page, slug: uniqueSlug(slugify(page.name), fixedSlugs) };
// Demoted landing page (or a stray extra 'index' page -- see doc
// comment above) -- give it a real, unique slug of its own.
const newSlug = uniqueSlug(slugify(page.name), usedSlugs);
usedSlugs.push(newSlug);
return { ...page, slug: newSlug };
}
return page;
});
@@ -487,21 +494,25 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
*/
const duplicatePage = useCallback(
(pageId: string) => {
// Always persist whatever is on the live canvas back into its page
// slot BEFORE any teardown below (same as addPage/switchPage/deletePage
// do unconditionally). Without this, duplicating a page OTHER than the
// active one would tear down and switch the canvas via loadState()
// further down without ever serializing the outgoing active page's
// live edits into its slot -- silently discarding them.
saveCurrentState();
const isActive = pageId === activePageIdRef.current;
// If the source is the active page, persist its current on-canvas
// state back into `pages` first (same as switchPage/addPage do)
// so the ORIGINAL page isn't left with a stale craftState after
// this. `query.serialize()` below reads the live canvas directly
// rather than waiting on this (React state update timing aside,
// it's simplest to just ask Craft.js for the truth).
if (isActive) {
saveCurrentState();
}
const source = pagesRef.current.find((p) => p.id === pageId);
if (!source) return;
// If the source IS the active page, saveCurrentState() above just
// wrote the live canvas into `source.craftState`'s slot -- but
// `pagesRef.current` (captured above) may still be the pre-update
// snapshot depending on render timing, so ask Craft.js directly for
// the same value rather than re-reading the ref. If the source is a
// NON-active page, its stored craftState is untouched by saving the
// (different) active page above, so use it as-is.
const sourceCraftState = isActive ? query.serialize() : source.craftState;
const otherSlugs = pagesRef.current.map((p) => p.slug);
const copyId = nextPageId();