From 18d79a608ff0ee54d62f5cf39b29da694f0f266d Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Tue, 3 Nov 2026 20:35:35 +0000 Subject: [PATCH] fix: Preserve existing variables when inserting Marketplace pages --- .../features/marketplace/templates.tsx | 11 +++-- .../runtime-operation-contracts.ts | 3 ++ .../project-build/src/runtime/page-copy.ts | 40 +++++++++++++++++-- 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/apps/builder/app/builder/features/marketplace/templates.tsx b/apps/builder/app/builder/features/marketplace/templates.tsx index b7a1ff4a06..e00525068b 100644 --- a/apps/builder/app/builder/features/marketplace/templates.tsx +++ b/apps/builder/app/builder/features/marketplace/templates.tsx @@ -3,6 +3,7 @@ import { insertWebstudioFragmentAt } from "~/shared/instance-utils/insert"; import { detectPageTokenConflicts, extractWebstudioFragment, + preserveExistingRootVariables, } from "@webstudio-is/project-build/runtime"; import { executeRuntimeMutation } from "~/shared/instance-utils/data"; import { useMemo } from "react"; @@ -101,9 +102,12 @@ const insertPage = async ({ return; } const rootStyleTargetData = getWebstudioData(); - const rootFragment = extractWebstudioFragment(sourceData, ROOT_INSTANCE_ID); + const safeSourceData = preserveExistingRootVariables({ + sourceData, + targetData: rootStyleTargetData, + }); const rootStyleConflictResolution = await resolveRootStyleConflicts({ - fragment: rootFragment, + fragment: extractWebstudioFragment(safeSourceData, ROOT_INSTANCE_ID), targetData: rootStyleTargetData, }); if (rootStyleConflictResolution === "cancel") { @@ -116,7 +120,8 @@ const insertPage = async ({ const result = executeRuntimeMutation({ id: "pages.copy", input: { - sourceData, + sourceData: safeSourceData, + preserveExistingRootVariables: true, pageId, parentFolderId: ROOT_FOLDER_ID, projectId, diff --git a/packages/project-build/src/contracts/__generated__/runtime-operation-contracts.ts b/packages/project-build/src/contracts/__generated__/runtime-operation-contracts.ts index 2022d4ecc1..f7e2f458f5 100644 --- a/packages/project-build/src/contracts/__generated__/runtime-operation-contracts.ts +++ b/packages/project-build/src/contracts/__generated__/runtime-operation-contracts.ts @@ -6843,6 +6843,9 @@ export const runtimeOperationContractData = [ type: "string", enum: ["ours", "theirs"], }, + preserveExistingRootVariables: { + type: "boolean", + }, }, required: ["sourceData", "pageId"], }, diff --git a/packages/project-build/src/runtime/page-copy.ts b/packages/project-build/src/runtime/page-copy.ts index 655ea7d834..a7abdb8117 100644 --- a/packages/project-build/src/runtime/page-copy.ts +++ b/packages/project-build/src/runtime/page-copy.ts @@ -35,6 +35,7 @@ import { type WebstudioFragment, type WebstudioData, ROOT_INSTANCE_ID, + getStyleDeclKey, } from "@webstudio-is/sdk"; import type { @@ -119,6 +120,32 @@ const sourceWebstudioDataInput = z "Required hydrated WebstudioData from the source project. This is a low-level cross-project transfer command; use duplicate-page to copy a page within the configured project." ); +export const preserveExistingRootVariables = ({ + sourceData, + targetData, +}: { + sourceData: WebstudioData; + targetData: WebstudioData; +}): WebstudioData => { + const fragment = extractWebstudioFragment(sourceData, ROOT_INSTANCE_ID); + const conflictingVariables = new Set( + detectFragmentRootStyleConflicts({ fragment, targetData }) + .filter(({ incomingStyle }) => incomingStyle.property.startsWith("--")) + .map(({ incomingStyle }) => getStyleDeclKey(incomingStyle)) + ); + if (conflictingVariables.size === 0) { + return sourceData; + } + return { + ...sourceData, + styles: new Map( + Array.from(sourceData.styles).filter( + ([key]) => conflictingVariables.has(key) === false + ) + ), + }; +}; + export const pageCopyInput = z.object({ projectId: z.string(), sourceData: sourceWebstudioDataInput, @@ -133,6 +160,7 @@ export const pageCopyInput = z.object({ .describe( 'How to resolve conflicting global root-local declarations: "ours" keeps the target project values; "theirs" uses the incoming source values. Required when conflicts exist.' ), + preserveExistingRootVariables: z.boolean().optional(), }); export const folderDuplicateInput = z.object({ @@ -926,19 +954,25 @@ export const copyPage = ( build: data, assets: Array.from(data.assets.values()), }); - const sourcePage = findPageByIdOrPath(input.pageId, input.sourceData.pages); + const sourceData = input.preserveExistingRootVariables + ? preserveExistingRootVariables({ + sourceData: input.sourceData, + targetData: before, + }) + : input.sourceData; + const sourcePage = findPageByIdOrPath(input.pageId, sourceData.pages); if (sourcePage === undefined) { return throwBuilderRuntimeError("BAD_REQUEST", "Page could not be copied"); } requireRootStyleConflictResolution({ - rootFragment: extractWebstudioFragment(input.sourceData, ROOT_INSTANCE_ID), + rootFragment: extractWebstudioFragment(sourceData, ROOT_INSTANCE_ID), targetData: before, resolution: input.rootStyleConflictResolution, }); let pageId: Page["id"] | undefined; const { payload } = produceWebstudioDataMutation(before, (draft) => { pageId = insertPageCopyMutable({ - source: { data: input.sourceData, pageId: input.pageId }, + source: { data: sourceData, pageId: input.pageId }, target: { data: draft, folderId: parentFolderId }, projectId: input.projectId, conflictResolution: input.conflictResolution,