diff --git a/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts b/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts index 8b8705ed19..e246aa3943 100644 --- a/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts +++ b/apps/builder/app/builder/features/settings-panel/props-section/use-props-logic.ts @@ -4,6 +4,7 @@ import { type PropMeta, showAttribute } from "@webstudio-is/react-sdk"; import type { PropValue } from "../shared"; import { useStore } from "@nanostores/react"; import { + computeExpression, dataSourcesLogicStore, registeredComponentPropsMetasStore, } from "~/shared/nano-states"; @@ -162,7 +163,7 @@ export const usePropsLogic = ({ const savedProps = props.flatMap((prop) => { if (prop.type === "expression") { // convert expression prop to value prop - const dataSourceValue = dataSourcesLogic.get(prop.id); + const dataSourceValue = computeExpression(prop.value, dataSourcesLogic); return [ { id: prop.id, diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx index bc3d717e5d..c0f3a8e3e5 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -26,6 +26,7 @@ import { getIndexesWithinAncestors, } from "@webstudio-is/react-sdk"; import { + computeExpression, dataSourcesLogicStore, instancesStore, registeredComponentMetasStore, @@ -164,7 +165,7 @@ const useInstanceProps = (instanceId: Instance["id"]) => { continue; } if (prop.type === "expression") { - const value = dataSourcesLogic.get(prop.id); + const value = computeExpression(prop.value, dataSourcesLogic); if (value !== undefined) { instancePropsObject[prop.name] = value; } diff --git a/apps/builder/app/shared/nano-states/nano-states.ts b/apps/builder/app/shared/nano-states/nano-states.ts index 7547c8b874..b20cf58fad 100644 --- a/apps/builder/app/shared/nano-states/nano-states.ts +++ b/apps/builder/app/shared/nano-states/nano-states.ts @@ -18,7 +18,10 @@ import { type StyleSources, type StyleSourceSelections, } from "@webstudio-is/sdk"; -import { generateDataSources } from "@webstudio-is/react-sdk"; +import { + encodeDataSourceVariable, + generateDataSources, +} from "@webstudio-is/react-sdk"; import type { Style } from "@webstudio-is/css-engine"; import type { DragStartPayload } from "~/canvas/shared/use-drag-drop"; import { shallowComputed } from "../store-utils"; @@ -112,6 +115,29 @@ export const dataSourcesLogicStore = computed( } ); +export const computeExpression = ( + expression: string, + variables: Map +) => { + let code = ""; + for (const [id, value] of variables) { + // @todo pass dedicated map of variables without actions + // skip actions + if (typeof value === "function") { + continue; + } + const identifier = encodeDataSourceVariable(id); + code += `const ${identifier} = ${JSON.stringify(value)};\n`; + } + code += `return (${expression})`; + try { + const result = new Function(code)(); + return result; + } catch { + // empty block + } +}; + export const stylesStore = atom(new Map()); export const useInstanceStyles = (instanceId: undefined | Instance["id"]) => { diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx index 7704f934f0..054c59e03e 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx @@ -73,31 +73,23 @@ export const projectId = "cddc1d44-af37-4cb6-a430-d300cf6f932d"; const Page = (props: { scripts?: ReactNode }) => { let [formState, set$formState] = useState("initial"); let [formState_1, set$formState_1] = useState("initial"); - let state = formState; let onStateChange = (state: any) => { formState = state; set$formState(formState); }; - let datawsshow = formState === "initial" || formState === "error"; - let datawsshow_1 = formState === "success"; - let datawsshow_2 = formState === "error"; - let state_1 = formState_1; let onStateChange_1 = (state: any) => { formState_1 = state; set$formState_1(formState_1); }; - let datawsshow_3 = formState_1 === "initial" || formState_1 === "error"; - let datawsshow_4 = formState_1 === "success"; - let datawsshow_5 = formState_1 === "error"; return (
- {datawsshow_3 && ( + {(formState_1 === "initial" || formState_1 === "error") && ( { )} - {datawsshow_4 && ( + {formState_1 === "success" && ( {"Thank you for getting in touch!"} )} - {datawsshow_5 && ( + {formState_1 === "error" && ( {"Sorry, something went wrong."} @@ -144,12 +136,12 @@ const Page = (props: { scripts?: ReactNode }) => { - {datawsshow && ( + {(formState === "initial" || formState === "error") && ( { )} - {datawsshow_1 && ( + {formState === "success" && ( {"Thank you for getting in touch!"} )} - {datawsshow_2 && ( + {formState === "error" && ( {"Sorry, something went wrong."} diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[radix]._index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[radix]._index.tsx index 7566026685..c785cca01c 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/[radix]._index.tsx +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[radix]._index.tsx @@ -81,7 +81,6 @@ export const projectId = "cddc1d44-af37-4cb6-a430-d300cf6f932d"; const Page = (props: { scripts?: ReactNode }) => { let [accordionValue, set$accordionValue] = useState("0"); - let value = accordionValue; let onValueChange = (value: any) => { accordionValue = value; set$accordionValue(accordionValue); @@ -92,7 +91,7 @@ const Page = (props: { scripts?: ReactNode }) => { data-ws-id="AM9fD6dv2Ftc3Xjcsd7Uc" data-ws-component="@webstudio-is/sdk-components-react-radix:Accordion" collapsible={true} - value={value} + value={accordionValue} onValueChange={onValueChange} > { `) @@ -358,7 +358,7 @@ test("generate jsx element with condition based on show prop", () => { }) ).toEqual( clear(` - {datawsshow && + {(conditionName) && @@ -529,7 +529,6 @@ test("generate page component with variables and actions", () => { clear(` const Page = (props: { scripts?: ReactNode }) => { let [variableName, set$variableName] = useState("initial") - let value = (variableName); let onChange = (value: any) => { variableName = value set$variableName(variableName) @@ -541,7 +540,7 @@ test("generate page component with variables and actions", () => { data-ws-id="input" data-ws-component="Input" data-ws-index="0" - value={value} + value={variableName} onChange={onChange} /> {props.scripts} diff --git a/packages/react-sdk/src/component-generator.ts b/packages/react-sdk/src/component-generator.ts index 7d3286d444..634cf34106 100644 --- a/packages/react-sdk/src/component-generator.ts +++ b/packages/react-sdk/src/component-generator.ts @@ -4,6 +4,7 @@ import type { Props, Scope, DataSources, + Prop, } from "@webstudio-is/sdk"; import { parseComponentName } from "@webstudio-is/sdk"; import { @@ -12,9 +13,56 @@ import { indexAttribute, showAttribute, } from "./tree/webstudio-component"; -import { generateDataSources } from "./expression"; +import { + decodeDataSourceVariable, + generateDataSources, + validateExpression, +} from "./expression"; import type { IndexesWithinAncestors } from "./instance-utils"; +const generatePropValue = ({ + scope, + prop, + dataSources, +}: { + scope: Scope; + prop: Prop; + dataSources: DataSources; +}) => { + // ignore asset and page props which are handled by components internally + if (prop.type === "asset" || prop.type === "page") { + return; + } + if ( + prop.type === "string" || + prop.type === "number" || + prop.type === "boolean" || + prop.type === "string[]" || + prop.type === "json" + ) { + return JSON.stringify(prop.value); + } + // inline expression to safely use collection item + if (prop.type === "expression") { + return validateExpression(prop.value, { + // transpile to safely executable member expressions + optional: true, + transformIdentifier: (identifier) => { + const depId = decodeDataSourceVariable(identifier); + const dep = depId ? dataSources.get(depId) : undefined; + if (dep) { + return scope.getName(dep.id, dep.name); + } + return identifier; + }, + }); + } + if (prop.type === "action") { + return scope.getName(prop.id, prop.name); + } + prop satisfies never; +}; + export const generateJsxElement = ({ scope, instance, @@ -30,7 +78,7 @@ export const generateJsxElement = ({ indexesWithinAncestors: IndexesWithinAncestors; children: string; }) => { - let conditionVariableName: undefined | string; + let conditionValue: undefined | string; let generatedProps = ""; @@ -48,50 +96,31 @@ export const generateJsxElement = ({ if (prop.instanceId !== instance.id) { continue; } + const propValue = generatePropValue({ scope, prop, dataSources }); // show prop controls conditional rendering and need to be handled separately if (prop.name === showAttribute) { - // prevent instance rendering when hidden - if (prop.type === "boolean" && prop.value === false) { + // prevent generating unnecessary condition + if (propValue === "true") { + continue; + } + // prevent instance rendering when always hidden + if (propValue === "false") { return ""; } - if (prop.type === "expression") { - conditionVariableName = scope.getName(prop.id, prop.name); - } - // ignore any other values + conditionValue = propValue; + // generate separately continue; } - if ( - prop.type === "string" || - prop.type === "number" || - prop.type === "boolean" || - prop.type === "string[]" || - prop.type === "json" - ) { - generatedProps += `\n${prop.name}={${JSON.stringify(prop.value)}}`; - continue; + if (propValue !== undefined) { + generatedProps += `\n${prop.name}={${propValue}}`; } - // ignore asset and page props which are handled by components internally - if (prop.type === "asset" || prop.type === "page") { - continue; - } - if (prop.type === "expression") { - const propVariable = scope.getName(prop.id, prop.name); - generatedProps += `\n${prop.name}={${propVariable}}`; - continue; - } - if (prop.type === "action") { - const propVariable = scope.getName(prop.id, prop.name); - generatedProps += `\n${prop.name}={${propVariable}}`; - continue; - } - prop satisfies never; } let generatedElement = ""; // coditionally render instance when show prop is data source // {dataSourceVariable && } - if (conditionVariableName) { - generatedElement += `{${conditionVariableName} &&\n`; + if (conditionValue) { + generatedElement += `{(${conditionValue}) &&\n`; } const [_namespace, shortName] = parseComponentName(instance.component); @@ -104,7 +133,7 @@ export const generateJsxElement = ({ generatedElement += `\n`; } - if (conditionVariableName) { + if (conditionValue) { generatedElement += `}\n`; } diff --git a/packages/react-sdk/src/expression.test.ts b/packages/react-sdk/src/expression.test.ts index 6300702095..4667b910b3 100644 --- a/packages/react-sdk/src/expression.test.ts +++ b/packages/react-sdk/src/expression.test.ts @@ -267,67 +267,3 @@ test("generate variables with actions", () => { ]) ); }); - -test("generate variables witha prop expressions", () => { - const generated = generateDataSources({ - scope: createScope(), - dataSources: new Map([ - [ - "dataSource1", - { - id: "dataSource1", - scopeInstanceId: "instance1", - type: "variable", - name: "myVar", - value: { type: "string", value: "initial" }, - }, - ], - ]), - props: new Map([ - [ - "prop1", - { - id: "prop1", - instanceId: "instance1", - type: "expression", - name: "exp", - value: `$ws$dataSource$dataSource1 + "Name"`, - }, - ], - [ - "prop2", - { - id: "prop2", - instanceId: "instance2", - type: "expression", - name: "exp", - value: `$ws$dataSource$dataSource1 + "Value"`, - }, - ], - ]), - }); - expect(generated.body).toMatchInlineSnapshot(` -"let exp = (myVar + "Name"); -let exp_1 = (myVar + "Value"); -" -`); - expect(generated.variables).toEqual( - new Map([ - [ - "dataSource1", - { - initialValue: "initial", - setterName: "set$myVar", - valueName: "myVar", - }, - ], - ]) - ); - expect(generated.output).toEqual( - new Map([ - ["dataSource1", "myVar"], - ["prop1", "exp"], - ["prop2", "exp_1"], - ]) - ); -}); diff --git a/packages/react-sdk/src/expression.ts b/packages/react-sdk/src/expression.ts index 91d82d7104..cd89c69114 100644 --- a/packages/react-sdk/src/expression.ts +++ b/packages/react-sdk/src/expression.ts @@ -249,25 +249,6 @@ export const generateDataSources = ({ } for (const prop of props.values()) { - // generate prop expressions - if (prop.type === "expression") { - const name = scope.getName(prop.id, prop.name); - output.set(prop.id, name); - const code = validateExpression(prop.value, { - transformIdentifier: (identifier) => { - const depId = decodeDataSourceVariable(identifier); - const dep = depId ? dataSources.get(depId) : undefined; - if (dep) { - return scope.getName(dep.id, dep.name); - } - // eslint-disable-next-line no-console - console.error(`Unknown dependency "${identifier}"`); - return identifier; - }, - }); - body += `let ${name} = (${code});\n`; - } - // generate actions assigning variables and invoking their setters if (prop.type === "action") { const name = scope.getName(prop.id, prop.name); diff --git a/packages/sdk-components-react-radix/src/__generated__/accordion.stories.tsx b/packages/sdk-components-react-radix/src/__generated__/accordion.stories.tsx index 94c04d7f30..85ea8ac9bd 100644 --- a/packages/sdk-components-react-radix/src/__generated__/accordion.stories.tsx +++ b/packages/sdk-components-react-radix/src/__generated__/accordion.stories.tsx @@ -14,7 +14,6 @@ import { const Page = (props: { scripts?: ReactNode }) => { let [accordionValue, set$accordionValue] = useState("0"); - let value = accordionValue; let onValueChange = (value: any) => { accordionValue = value; set$accordionValue(accordionValue); @@ -25,7 +24,7 @@ const Page = (props: { scripts?: ReactNode }) => { data-ws-id="1" data-ws-component="Accordion" collapsible={true} - value={value} + value={accordionValue} onValueChange={onValueChange} > { let [checkboxChecked, set$checkboxChecked] = useState(false); - let checked = checkboxChecked; let onCheckedChange = (checked: any) => { checkboxChecked = checked; set$checkboxChecked(checkboxChecked); @@ -23,7 +22,7 @@ const Page = (props: { scripts?: ReactNode }) => { { let [collapsibleOpen, set$collapsibleOpen] = useState(false); - let open = collapsibleOpen; let onOpenChange = (open: any) => { collapsibleOpen = open; set$collapsibleOpen(collapsibleOpen); @@ -22,7 +21,7 @@ const Page = (props: { scripts?: ReactNode }) => { { let [dialogOpen, set$dialogOpen] = useState(false); - let open = dialogOpen; let onOpenChange = (open: any) => { dialogOpen = open; set$dialogOpen(dialogOpen); @@ -27,7 +26,7 @@ const Page = (props: { scripts?: ReactNode }) => { diff --git a/packages/sdk-components-react-radix/src/__generated__/navigation-menu.stories.tsx b/packages/sdk-components-react-radix/src/__generated__/navigation-menu.stories.tsx index a680b62b88..b35ccf13a5 100644 --- a/packages/sdk-components-react-radix/src/__generated__/navigation-menu.stories.tsx +++ b/packages/sdk-components-react-radix/src/__generated__/navigation-menu.stories.tsx @@ -19,7 +19,6 @@ import { const Page = (props: { scripts?: ReactNode }) => { let [menuValue, set$menuValue] = useState(""); - let value = menuValue; let onValueChange = (value: any) => { menuValue = value; set$menuValue(menuValue); @@ -29,7 +28,7 @@ const Page = (props: { scripts?: ReactNode }) => { { let [popoverOpen, set$popoverOpen] = useState(false); - let open = popoverOpen; let onOpenChange = (open: any) => { popoverOpen = open; set$popoverOpen(popoverOpen); @@ -22,7 +21,7 @@ const Page = (props: { scripts?: ReactNode }) => { diff --git a/packages/sdk-components-react-radix/src/__generated__/radio-group.stories.tsx b/packages/sdk-components-react-radix/src/__generated__/radio-group.stories.tsx index 02dd35bec7..d57b356e13 100644 --- a/packages/sdk-components-react-radix/src/__generated__/radio-group.stories.tsx +++ b/packages/sdk-components-react-radix/src/__generated__/radio-group.stories.tsx @@ -13,7 +13,6 @@ import { const Page = (props: { scripts?: ReactNode }) => { let [radioGroupValue, set$radioGroupValue] = useState(""); - let value = radioGroupValue; let onValueChange = (value: any) => { radioGroupValue = value; set$radioGroupValue(radioGroupValue); @@ -23,7 +22,7 @@ const Page = (props: { scripts?: ReactNode }) => {