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", () => {
$.Box>
$.Body>
);
- 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", () => {
$.Box>
$.Body>
);
- 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", () => {
$.Box>
$.Body>
);
- 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}`}
+ $.Text>
+ $.Body>
+ );
+ 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", () => {
$.Box>
$.Body>
);
- 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", () => {
$.Box>
$.Body>
);
- 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`}$.Text>
+ $.Body>
+
+ );
+ 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}`}$.Text>
+ $.Body>
+ );
+ 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", () => {
$.Slot>
$.Body>
);
- 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][] = [];