diff --git a/apps/builder/app/shared/data-variables.test.tsx b/apps/builder/app/shared/data-variables.test.tsx index 817a62abb122..bb61d41c37e9 100644 --- a/apps/builder/app/shared/data-variables.test.tsx +++ b/apps/builder/app/shared/data-variables.test.tsx @@ -262,7 +262,11 @@ test("restore tree variables in children", () => { ); - rebindTreeVariablesMutable({ startingInstanceId: "boxId", ...data }); + rebindTreeVariablesMutable({ + startingInstanceId: "boxId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ scopeInstanceId: "bodyId" }), expect.objectContaining({ scopeInstanceId: "boxId" }), @@ -290,7 +294,11 @@ test("restore tree variables in props", () => { ); - rebindTreeVariablesMutable({ startingInstanceId: "boxId", ...data }); + rebindTreeVariablesMutable({ + startingInstanceId: "boxId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); const [_bodyVariableId, boxOneVariableId, boxTwoVariableId] = data.dataSources.keys(); const boxOneIdentifier = encodeDataVariableId(boxOneVariableId); @@ -337,7 +345,11 @@ test("rebind tree variables in props and children", () => { ); - rebindTreeVariablesMutable({ startingInstanceId: "boxId", ...data }); + rebindTreeVariablesMutable({ + startingInstanceId: "boxId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ scopeInstanceId: "bodyId" }), expect.objectContaining({ scopeInstanceId: "boxId" }), @@ -358,6 +370,32 @@ test("rebind tree variables in props and children", () => { ]); }); +test("preserve nested variables with the same name when rebind", () => { + const bodyVariable = new Variable("one", "one value of body"); + const textVariable = new Variable("one", "one value of box"); + const data = renderData( + <$.Body ws:id="bodyId" data-body-vars={expression`${bodyVariable}`}> + <$.Text ws:id="textId" data-text-vars={expression`${textVariable}`}> + {expression`${textVariable}`} + + + ); + rebindTreeVariablesMutable({ + startingInstanceId: "bodyId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); + expect(Array.from(data.dataSources.values())).toEqual([ + expect.objectContaining({ scopeInstanceId: "bodyId" }), + expect.objectContaining({ scopeInstanceId: "textId" }), + ]); + const [_bodyVariableId, textVariableId] = data.dataSources.keys(); + const textIdentifier = encodeDataVariableId(textVariableId); + expect(data.instances.get("textId")?.children).toEqual([ + { type: "expression", value: textIdentifier }, + ]); +}); + test("restore tree variables in resources", () => { const bodyVariable = new Variable("one", "one value of body"); const boxVariable = new Variable("one", "one value of box"); @@ -384,7 +422,11 @@ test("restore tree variables in resources", () => { ); - rebindTreeVariablesMutable({ startingInstanceId: "boxId", ...data }); + rebindTreeVariablesMutable({ + startingInstanceId: "boxId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ scopeInstanceId: "bodyId" }), expect.objectContaining({ scopeInstanceId: "boxId" }), @@ -434,7 +476,11 @@ test("rebind tree variables in resources", () => { ); - rebindTreeVariablesMutable({ startingInstanceId: "boxId", ...data }); + rebindTreeVariablesMutable({ + startingInstanceId: "boxId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); expect(Array.from(data.dataSources.values())).toEqual([ expect.objectContaining({ scopeInstanceId: "bodyId" }), expect.objectContaining({ scopeInstanceId: "boxId" }), @@ -458,6 +504,55 @@ test("rebind tree variables in resources", () => { ]); }); +test("rebind global variables in resources", () => { + const globalVariable = new Variable("globalVariable", ""); + const data = renderData( + + <$.Body ws:id="bodyId"> + <$.Text ws:id="textId">{expression`globalVariable`} + + + ); + data.instances.delete(ROOT_INSTANCE_ID); + rebindTreeVariablesMutable({ + startingInstanceId: ROOT_INSTANCE_ID, + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); + expect(Array.from(data.dataSources.values())).toEqual([ + expect.objectContaining({ scopeInstanceId: ROOT_INSTANCE_ID }), + ]); + const [globalVariableId] = data.dataSources.keys(); + const globalIdentifier = encodeDataVariableId(globalVariableId); + expect(data.instances.get("textId")?.children).toEqual([ + { type: "expression", value: globalIdentifier }, + ]); +}); + +test("preserve other variables when rebind", () => { + const bodyVariable = new Variable("globalVariable", ""); + const textVariable = new Variable("textVariable", ""); + const data = renderData( + <$.Body ws:id="bodyId" data-vars={expression`${bodyVariable}`}> + <$.Text ws:id="textId">{expression`${textVariable}`} + + ); + rebindTreeVariablesMutable({ + startingInstanceId: "bodyId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); + expect(Array.from(data.dataSources.values())).toEqual([ + expect.objectContaining({ scopeInstanceId: "bodyId" }), + expect.objectContaining({ scopeInstanceId: "textId" }), + ]); + const [_globalVariableId, textVariableId] = data.dataSources.keys(); + const textIdentifier = encodeDataVariableId(textVariableId); + expect(data.instances.get("textId")?.children).toEqual([ + { type: "expression", value: textIdentifier }, + ]); +}); + test("prevent rebinding tree variables from slots", () => { const bodyVariable = new Variable("myVariable", "one value of body"); const data = renderData( @@ -469,7 +564,11 @@ test("prevent rebinding tree variables from slots", () => { ); - rebindTreeVariablesMutable({ startingInstanceId: "boxId", ...data }); + rebindTreeVariablesMutable({ + startingInstanceId: "boxId", + pages: createDefaultPages({ rootInstanceId: "bodyId" }), + ...data, + }); expect(data.instances.get("boxId")?.children).toEqual([ { type: "expression", value: "myVariable" }, ]); diff --git a/apps/builder/app/shared/data-variables.ts b/apps/builder/app/shared/data-variables.ts index e71b2f327545..0ea177e99595 100644 --- a/apps/builder/app/shared/data-variables.ts +++ b/apps/builder/app/shared/data-variables.ts @@ -275,7 +275,11 @@ const traverseExpressions = ({ props: Props; dataSources: DataSources; resources: Resources; - update: (expression: string, args?: string[]) => void | string; + update: ( + expression: string, + instanceId: Instance["id"], + args?: string[] + ) => void | string; }) => { const pagesList = pages ? [pages.homePage, ...pages.pages] : []; @@ -297,37 +301,43 @@ const traverseExpressions = ({ startingInstanceId === page.rootInstanceId || startingInstanceId === ROOT_INSTANCE_ID ) { - page.title = update(page.title) ?? page.title; + const { rootInstanceId } = page; + page.title = update(page.title, rootInstanceId) ?? page.title; if (page.meta.description) { page.meta.description = - update(page.meta.description) ?? page.meta.description; + update(page.meta.description, rootInstanceId) ?? + page.meta.description; } if (page.meta.excludePageFromSearch) { page.meta.excludePageFromSearch = - update(page.meta.excludePageFromSearch) ?? + update(page.meta.excludePageFromSearch, rootInstanceId) ?? page.meta.excludePageFromSearch; } if (page.meta.socialImageUrl) { page.meta.socialImageUrl = - update(page.meta.socialImageUrl) ?? page.meta.socialImageUrl; + update(page.meta.socialImageUrl, rootInstanceId) ?? + page.meta.socialImageUrl; } if (page.meta.language) { - page.meta.language = update(page.meta.language) ?? page.meta.language; + page.meta.language = + update(page.meta.language, rootInstanceId) ?? page.meta.language; } if (page.meta.status) { - page.meta.status = update(page.meta.status) ?? page.meta.status; + page.meta.status = + update(page.meta.status, rootInstanceId) ?? page.meta.status; } if (page.meta.redirect) { - page.meta.redirect = update(page.meta.redirect) ?? page.meta.redirect; + page.meta.redirect = + update(page.meta.redirect, rootInstanceId) ?? page.meta.redirect; } if (page.meta.custom) { for (const item of page.meta.custom) { - item.content = update(item.content) ?? item.content; + item.content = update(item.content, rootInstanceId) ?? item.content; } } } } - const resourceIds = new Set(); + const instanceIdByResourceId = new Map(); for (const instance of instances.values()) { if (instanceIds.has(instance.id) === false) { @@ -335,7 +345,7 @@ const traverseExpressions = ({ } for (const child of instance.children) { if (child.type === "expression") { - child.value = update(child.value) ?? child.value; + child.value = update(child.value, instance.id) ?? child.value; } } } @@ -345,40 +355,40 @@ const traverseExpressions = ({ continue; } if (prop.type === "expression") { - prop.value = update(prop.value) ?? prop.value; + prop.value = update(prop.value, prop.instanceId) ?? prop.value; continue; } if (prop.type === "action") { for (const action of prop.value) { - action.code = update(action.code, action.args) ?? action.code; + action.code = + update(action.code, prop.instanceId, action.args) ?? action.code; } continue; } if (prop.type === "resource") { - resourceIds.add(prop.value); + instanceIdByResourceId.set(prop.value, prop.instanceId); continue; } } for (const dataSource of dataSources.values()) { - if ( - instanceIds.has(dataSource.scopeInstanceId ?? "") && - dataSource.type === "resource" - ) { - resourceIds.add(dataSource.resourceId); + const instanceId = dataSource.scopeInstanceId ?? ""; + if (instanceIds.has(instanceId) && dataSource.type === "resource") { + instanceIdByResourceId.set(dataSource.resourceId, instanceId); } } for (const resource of resources.values()) { - if (resourceIds.has(resource.id) === false) { + const instanceId = instanceIdByResourceId.get(resource.id); + if (instanceId === undefined) { continue; } - resource.url = update(resource.url) ?? resource.url; + resource.url = update(resource.url, instanceId) ?? resource.url; for (const header of resource.headers) { - header.value = update(header.value) ?? header.value; + header.value = update(header.value, instanceId) ?? header.value; } if (resource.body) { - resource.body = update(resource.body) ?? resource.body; + resource.body = update(resource.body, instanceId) ?? resource.body; } } }; @@ -404,7 +414,7 @@ export const findUnsetVariableNames = ({ props, dataSources, resources, - update: (expression, args = []) => { + update: (expression, _instanceId, args = []) => { transpileExpression({ expression, replaceVariable: (identifier) => { @@ -458,34 +468,38 @@ export const findUsedVariables = ({ export const rebindTreeVariablesMutable = ({ startingInstanceId, + pages, instances, props, dataSources, resources, }: { startingInstanceId: Instance["id"]; + pages: undefined | Pages; instances: Instances; props: Props; dataSources: DataSources; resources: Resources; }) => { - const maskedVariables = findMaskedVariablesByInstanceId({ - startingInstanceId, - dataSources, - instances, - }); + // unset all variables const unsetNameById = new Map(); - for (const { id, name } of dataSources.values()) { - unsetNameById.set(id, name); + for (const dataSource of dataSources.values()) { + unsetNameById.set(dataSource.id, dataSource.name); } traverseExpressions({ startingInstanceId, - pages: undefined, + pages, instances, props, dataSources, resources, - update: (expression, args) => { + update: (expression, instanceId, args) => { + // restore all masked variables of current scope + const maskedVariables = findMaskedVariablesByInstanceId({ + startingInstanceId: instanceId, + dataSources, + instances, + }); let maskedIdByName = new Map(maskedVariables); if (args) { maskedIdByName = new Map(maskedIdByName); diff --git a/apps/builder/app/shared/nano-states/props.ts b/apps/builder/app/shared/nano-states/props.ts index 03f812d3dfa5..f8efa10e4448 100644 --- a/apps/builder/app/shared/nano-states/props.ts +++ b/apps/builder/app/shared/nano-states/props.ts @@ -36,7 +36,7 @@ import { uploadingFileDataToAsset } from "~/builder/shared/assets/asset-utils"; import { fetch } from "~/shared/fetch.client"; import { $selectedPage, getInstanceKey } from "../awareness"; import { computeExpression } from "../data-variables"; -import { $currentSystem, $currentSystemVariableId } from "../system"; +import { $currentSystem } from "../system"; export const assetBaseUrl = "/cgi/asset/"; @@ -168,14 +168,10 @@ const $unscopedVariableValues = computed( * circular updates */ const $loaderVariableValues = computed( - [ - $dataSources, - $dataSourceVariables, - $currentSystemVariableId, - $currentSystem, - ], - (dataSources, dataSourceVariables, systemVariableId, system) => { + [$dataSources, $dataSourceVariables, $selectedPage, $currentSystem], + (dataSources, dataSourceVariables, selectedPage, system) => { const values = new Map(); + values.set(SYSTEM_VARIABLE_ID, system); for (const [dataSourceId, dataSource] of dataSources) { if (dataSource.type === "variable") { values.set( @@ -185,7 +181,7 @@ const $loaderVariableValues = computed( } if (dataSource.type === "parameter") { let value = dataSourceVariables.get(dataSourceId); - if (dataSource.id === systemVariableId) { + if (dataSource.id === selectedPage?.systemDataSourceId) { value = system; } values.set(dataSourceId, value); diff --git a/apps/builder/app/shared/system.ts b/apps/builder/app/shared/system.ts index c7c38b1eeb8f..aa36f999847f 100644 --- a/apps/builder/app/shared/system.ts +++ b/apps/builder/app/shared/system.ts @@ -1,10 +1,5 @@ import { atom, computed } from "nanostores"; -import { - findPageByIdOrPath, - type Page, - type System, - SYSTEM_VARIABLE_ID, -} from "@webstudio-is/sdk"; +import { findPageByIdOrPath, type Page, type System } from "@webstudio-is/sdk"; import { compilePathnamePattern, matchPathnamePattern, @@ -14,12 +9,6 @@ import { $selectedPage } from "./awareness"; import { $pages, $publishedOrigin } from "./nano-states"; import { serverSyncStore } from "./sync"; -export const $currentSystemVariableId = computed( - $selectedPage, - // fallback to global system variable - (page) => page?.systemDataSourceId ?? SYSTEM_VARIABLE_ID -); - export const $systemDataByPage = atom( new Map>() ); diff --git a/fixtures/webstudio-features/.webstudio/data.json b/fixtures/webstudio-features/.webstudio/data.json index aaa100ed3307..ffdef2f45829 100644 --- a/fixtures/webstudio-features/.webstudio/data.json +++ b/fixtures/webstudio-features/.webstudio/data.json @@ -1,10 +1,10 @@ { "build": { - "id": "3173a7d8-1af3-4e23-87fd-94c3b0cb1018", + "id": "fce42d58-8a67-4f4b-8427-ea7500132e28", "projectId": "cddc1d44-af37-4cb6-a430-d300cf6f932d", - "version": 474, - "createdAt": "2025-01-22T17:59:53.714+00:00", - "updatedAt": "2025-01-22T17:59:53.714+00:00", + "version": 477, + "createdAt": "2025-02-28T14:52:36.082+00:00", + "updatedAt": "2025-02-28T14:52:36.082+00:00", "pages": { "meta": { "siteName": "KittyGuardedZone", @@ -3214,6 +3214,19 @@ "scopeInstanceId": "1NipO5NmaHukA_dxzfVRF", "name": "system" } + ], + [ + "rKNaaGjEYnq4XPiTrSlfe", + { + "type": "variable", + "id": "rKNaaGjEYnq4XPiTrSlfe", + "scopeInstanceId": ":root", + "name": "globalVariable", + "value": { + "type": "string", + "value": "globalValue" + } + } ] ], "resources": [ @@ -4294,6 +4307,10 @@ { "type": "id", "value": "sHn4Mh7DJ5X0RiETB-zVc" + }, + { + "type": "id", + "value": "ABEvi07-c3_3dU6v4w-MS" } ] } @@ -5136,6 +5153,20 @@ "label": "Link", "children": [] } + ], + [ + "ABEvi07-c3_3dU6v4w-MS", + { + "type": "instance", + "id": "ABEvi07-c3_3dU6v4w-MS", + "component": "Box", + "children": [ + { + "type": "expression", + "value": "$ws$dataSource$rKNaaGjEYnq4XPiTrSlfe" + } + ] + } ] ], "deployment": { diff --git a/fixtures/webstudio-features/app/__generated__/$resources.sitemap.xml.ts b/fixtures/webstudio-features/app/__generated__/$resources.sitemap.xml.ts index 1bad56e430a3..7b8438c2278b 100644 --- a/fixtures/webstudio-features/app/__generated__/$resources.sitemap.xml.ts +++ b/fixtures/webstudio-features/app/__generated__/$resources.sitemap.xml.ts @@ -1,26 +1,26 @@ export const sitemap = [ { path: "/", - lastModified: "2025-01-22", + lastModified: "2025-02-28", }, { path: "/_route_with_symbols_", - lastModified: "2025-01-22", + lastModified: "2025-02-28", }, { path: "/form", - lastModified: "2025-01-22", + lastModified: "2025-02-28", }, { path: "/heading-with-id", - lastModified: "2025-01-22", + lastModified: "2025-02-28", }, { path: "/resources", - lastModified: "2025-01-22", + lastModified: "2025-02-28", }, { path: "/nested/nested-page", - lastModified: "2025-01-22", + lastModified: "2025-02-28", }, ]; diff --git a/fixtures/webstudio-features/app/__generated__/[expressions]._index.tsx b/fixtures/webstudio-features/app/__generated__/[expressions]._index.tsx index 199ee50bba2a..c392f49818c4 100644 --- a/fixtures/webstudio-features/app/__generated__/[expressions]._index.tsx +++ b/fixtures/webstudio-features/app/__generated__/[expressions]._index.tsx @@ -8,6 +8,7 @@ import { Body as Body } from "@webstudio-is/sdk-components-react-router"; import { Heading as Heading, HtmlEmbed as HtmlEmbed, + Box as Box, } from "@webstudio-is/sdk-components-react"; export const siteName = "KittyGuardedZone"; @@ -32,6 +33,8 @@ export const pageBackgroundImageAssets: ImageAsset[] = []; const Page = (_props: { system: any }) => { let jsonResourceVariable = useResource("jsonResourceVariable_1"); let [jsonVar, set$jsonVar] = useVariableState({ hello: "world" }); + let [globalVariable, set$globalVariable] = + useVariableState("globalValue"); return ( @@ -47,6 +50,7 @@ console.log(a, b); `} className={`w-html-embed`} /> + {globalVariable} ); }; diff --git a/fixtures/webstudio-features/package.json b/fixtures/webstudio-features/package.json index 8b8c6f7c2b58..30d942db54c9 100644 --- a/fixtures/webstudio-features/package.json +++ b/fixtures/webstudio-features/package.json @@ -6,7 +6,7 @@ "dev": "react-router dev", "cli": "NODE_OPTIONS='--conditions=webstudio --import=tsx' webstudio", "fixtures:link": "pnpm cli link --link https://p-cddc1d44-af37-4cb6-a430-d300cf6f932d-dot-${BUILDER_HOST:-main.development.webstudio.is}'?authToken=1cdc6026-dd5b-4624-b89b-9bd45e9bcc3d'", - "fixtures:sync": "pnpm cli sync --buildId 3173a7d8-1af3-4e23-87fd-94c3b0cb1018 && pnpm prettier --write ./.webstudio/", + "fixtures:sync": "pnpm cli sync --buildId fce42d58-8a67-4f4b-8427-ea7500132e28 && pnpm prettier --write ./.webstudio/", "fixtures:build": "pnpm cli build --template react-router --template ./.template && pnpm prettier --write ./app/ ./package.json ./tsconfig.json" }, "private": true, diff --git a/packages/cli/src/prebuild.ts b/packages/cli/src/prebuild.ts index 638664695651..4ae6520d8ba2 100644 --- a/packages/cli/src/prebuild.ts +++ b/packages/cli/src/prebuild.ts @@ -48,6 +48,7 @@ import { coreMetas, SYSTEM_VARIABLE_ID, generateCss, + ROOT_INSTANCE_ID, } from "@webstudio-is/sdk"; import type { Data } from "@webstudio-is/http-client"; import { LOCAL_DATA_FILE } from "./config"; @@ -314,6 +315,8 @@ export const prebuild = async (options: { instanceMap, page.rootInstanceId ); + // support global data variables + pageInstanceSet.add(ROOT_INSTANCE_ID); const instances: [Instance["id"], Instance][] = siteData.build.instances.filter(([id]) => pageInstanceSet.has(id)); const dataSources: [DataSource["id"], DataSource][] = [];