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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user