diff --git a/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap b/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap
index 7471e25d64..c6696e3b73 100644
--- a/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap
+++ b/packages/core/src/__tests__/__snapshots__/qwik.test.ts.snap
@@ -10145,7 +10145,7 @@ exports[`qwik > page-with-symbol 1`] = `
});
};
",
- "low.js": "import { Symbol1 } from \\"./med.js\\";
+ "low.js": "import { SymbolHeaderSymbol } from \\"./med.js\\";
import {
Fragment,
@@ -10178,7 +10178,7 @@ export const MyComponentOnMount = (p) => {
return h(
Fragment,
null,
- h(Symbol1, {
+ h(SymbolHeaderSymbol, {
class: \\"c713ty2\\",
symbol: {
model: \\"page\\",
diff --git a/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap b/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap
index 1cf19a14be..d16de9b979 100644
--- a/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap
+++ b/packages/core/src/__tests__/builder/__snapshots__/builder.test.ts.snap
@@ -4925,3 +4925,117 @@ function MyComponent(props) {
export default MyComponent;
"
`;
+
+exports[`Symbol Serialization > Multiple symbols with different names 1`] = `
+"import {
+ SymbolPrimaryButton,
+ SymbolSecondaryButton,
+ SymbolFooterSection,
+} from \\"@components\\";
+
+export default function MyComponent(props) {
+ return (
+ <>
+
+
+
+ >
+ );
+}
+"
+`;
+
+exports[`Symbol Serialization > Symbol with basic metadata 1`] = `
+"import { SymbolBasicSymbol } from \\"@components\\";
+
+export default function MyComponent(props) {
+ return (
+
+ );
+}
+"
+`;
+
+exports[`Symbol Serialization > Symbol with entry name 1`] = `
+"import { SymbolHeaderNavigation } from \\"@components\\";
+
+export default function MyComponent(props) {
+ return (
+
+ );
+}
+"
+`;
+
+exports[`Symbol Serialization > Symbol with inputs as top-level props 1`] = `
+"import { SymbolButtonComponent } from \\"@components\\";
+
+export default function MyComponent(props) {
+ return (
+
+ );
+}
+"
+`;
diff --git a/packages/core/src/__tests__/builder/builder.test.ts b/packages/core/src/__tests__/builder/builder.test.ts
index 871e1de5c2..1fcf8d2946 100644
--- a/packages/core/src/__tests__/builder/builder.test.ts
+++ b/packages/core/src/__tests__/builder/builder.test.ts
@@ -34,6 +34,10 @@ import lazyLoadSection from '../data/builder/lazy-load-section.json?raw';
import localization from '../data/builder/localization.json?raw';
import slotsContent from '../data/builder/slots.json?raw';
import slots2Content from '../data/builder/slots2.json?raw';
+import symbolBasic from '../data/builder/symbol-basic.json?raw';
+import symbolMultiple from '../data/builder/symbol-multiple.json?raw';
+import symbolWithInputs from '../data/builder/symbol-with-inputs.json?raw';
+import symbolWithNamedEntry from '../data/builder/symbol-with-named-entry.json?raw';
import tagNameContent from '../data/builder/tag-name.json?raw';
import textBindings from '../data/builder/text-bindings.json?raw';
import advancedFor from '../data/for/advanced-for.raw.tsx?raw';
@@ -2094,3 +2098,165 @@ const bindingJson = {
],
},
};
+
+describe('Symbol Serialization', () => {
+ test('Symbol with basic metadata', () => {
+ const builderContent = JSON.parse(symbolBasic) as BuilderContent;
+ const component = builderContentToMitosisComponent(builderContent);
+ const mitosis = componentToMitosis(mitosisOptions)({ component });
+
+ // Verify symbol name is sanitized and prefixed
+ expect(component.children[0].name).toBe('SymbolBasicSymbol');
+ expect(mitosis).toMatchSnapshot();
+ });
+
+ test('Symbol with entry name', () => {
+ const builderContent = JSON.parse(symbolWithNamedEntry) as BuilderContent;
+ const component = builderContentToMitosisComponent(builderContent);
+ const mitosis = componentToMitosis(mitosisOptions)({ component });
+
+ // Verify symbol name is sanitized and prefixed
+ expect(component.children[0].name).toBe('SymbolHeaderNavigation');
+ expect(mitosis).toMatchSnapshot();
+ });
+
+ test('Symbol with inputs as top-level props', () => {
+ const builderContent = JSON.parse(symbolWithInputs) as BuilderContent;
+ const component = builderContentToMitosisComponent(builderContent);
+ const mitosis = componentToMitosis(mitosisOptions)({ component });
+
+ // Verify inputs are top-level bindings
+ const symbolNode = component.children[0];
+ expect(symbolNode.name).toBe('SymbolButtonComponent');
+ expect(symbolNode.bindings).toHaveProperty('buttonText');
+ expect(symbolNode.bindings).toHaveProperty('variant');
+ expect(symbolNode.bindings).toHaveProperty('isDisabled');
+ expect(symbolNode.bindings).toHaveProperty('count');
+ expect(symbolNode.bindings).toHaveProperty('config');
+ expect(symbolNode.bindings.symbol).toBeDefined();
+
+ // Verify symbol binding doesn't contain data anymore
+ const symbolBinding = JSON.parse(symbolNode.bindings.symbol!.code);
+ expect(symbolBinding.data).toBeUndefined();
+
+ expect(mitosis).toMatchSnapshot();
+ });
+
+ test('Multiple symbols with different names', () => {
+ const builderContent = JSON.parse(symbolMultiple) as BuilderContent;
+ const component = builderContentToMitosisComponent(builderContent);
+ const mitosis = componentToMitosis(mitosisOptions)({ component });
+
+ // Verify each symbol has unique name
+ expect(component.children[0].name).toBe('SymbolPrimaryButton');
+ expect(component.children[1].name).toBe('SymbolSecondaryButton');
+ expect(component.children[2].name).toBe('SymbolFooterSection');
+
+ // Verify inputs are extracted for each
+ expect(component.children[0].bindings).toHaveProperty('text');
+ expect(component.children[0].bindings).toHaveProperty('variant');
+ expect(component.children[1].bindings).toHaveProperty('text');
+ expect(component.children[1].bindings).toHaveProperty('variant');
+ expect(component.children[2].bindings).toHaveProperty('copyrightText');
+ expect(component.children[2].bindings).toHaveProperty('showSocialLinks');
+
+ expect(mitosis).toMatchSnapshot();
+ });
+
+ test('Symbol roundtrip: Builder -> Mitosis -> JSX -> Mitosis -> Builder', () => {
+ const original = JSON.parse(symbolWithInputs) as BuilderContent;
+
+ // Step 1: Builder JSON -> Mitosis Component
+ const mitosisComponent = builderContentToMitosisComponent(original);
+
+ // Step 2: Mitosis Component -> Mitosis JSX string (what AI sees)
+ const jsxString = componentToMitosis()({ component: mitosisComponent });
+
+ // Step 3: Mitosis JSX string -> Mitosis Component (after AI edits)
+ const parsedComponent = parseJsx(jsxString);
+
+ // Step 4: Mitosis Component -> Builder JSON
+ const backToBuilder = componentToBuilder()({ component: parsedComponent });
+
+ // Verify the symbol structure is preserved through full roundtrip
+ const roundtripSymbol = backToBuilder.data?.blocks?.[0];
+
+ expect(roundtripSymbol?.component?.name).toBeDefined();
+ expect(roundtripSymbol?.component?.options?.symbol).toBeDefined();
+ });
+
+ test('Symbol roundtrip: Named symbol converts back to "Symbol" component name', () => {
+ const original = JSON.parse(symbolWithInputs) as BuilderContent;
+
+ // Step 1: Builder -> Mitosis (named component)
+ const mitosisComponent = builderContentToMitosisComponent(original);
+ expect(mitosisComponent.children[0].name).toBe('SymbolButtonComponent');
+
+ // Step 2: Mitosis -> JSX string (what AI sees)
+ const jsxString = componentToMitosis()({ component: mitosisComponent });
+
+ // Step 3: JSX string -> Mitosis (after AI edits)
+ const parsedComponent = parseJsx(jsxString);
+
+ // Step 4: Mitosis -> Builder (should be "Symbol" not "SymbolButtonComponent")
+ const backToBuilder = componentToBuilder()({ component: parsedComponent });
+ const roundtripSymbol = backToBuilder.data?.blocks?.[0];
+
+ // CRITICAL: Builder Editor requires component.name === "Symbol"
+ expect(roundtripSymbol?.component?.name).toBe('Symbol');
+
+ // Verify symbol metadata is preserved
+ expect(roundtripSymbol?.component?.options?.symbol).toBeDefined();
+ expect(roundtripSymbol?.component?.options?.symbol?.entry).toBeDefined();
+
+ // Verify the display name is preserved for next roundtrip
+ expect(roundtripSymbol?.component?.options?.symbol?.name).toBe('Button Component');
+
+ // Verify inputs are merged back into symbol.data
+ expect(roundtripSymbol?.component?.options?.symbol?.data).toBeDefined();
+ expect(roundtripSymbol?.component?.options?.symbol?.data?.buttonText).toBe('Click me!');
+ });
+
+ test('Symbol roundtrip preserves symbol.name for re-conversion to JSX', () => {
+ // Simulate what MCP returns: symbol with name field
+ const builderWithSymbolName: BuilderContent = {
+ data: {
+ blocks: [
+ {
+ '@type': '@builder.io/sdk:Element',
+ '@version': 2,
+ id: 'builder-roundtrip-test',
+ component: {
+ name: 'Symbol',
+ options: {
+ symbol: {
+ entry: 'test-entry-123',
+ model: 'symbol',
+ name: 'Copyright Reserved', // This should be used for component naming
+ data: {},
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+
+ // Step 1: Builder -> Mitosis: should use symbol.name for component name
+ const mitosisComponent = builderContentToMitosisComponent(builderWithSymbolName);
+ expect(mitosisComponent.children[0].name).toBe('SymbolCopyrightReserved');
+
+ // Step 2: Mitosis -> JSX string (what AI sees)
+ const jsxString = componentToMitosis()({ component: mitosisComponent });
+
+ // Step 3: JSX string -> Mitosis (after AI edits)
+ const parsedComponent = parseJsx(jsxString);
+
+ // Step 4: Mitosis -> Builder: should preserve name and use "Symbol" as component name
+ const backToBuilder = componentToBuilder()({ component: parsedComponent });
+ const symbol = backToBuilder.data?.blocks?.[0];
+
+ expect(symbol?.component?.name).toBe('Symbol');
+ expect(symbol?.component?.options?.symbol?.name).toBe('Copyright Reserved');
+ });
+});
diff --git a/packages/core/src/__tests__/data/builder/symbol-basic.json b/packages/core/src/__tests__/data/builder/symbol-basic.json
new file mode 100644
index 0000000000..d09d06728b
--- /dev/null
+++ b/packages/core/src/__tests__/data/builder/symbol-basic.json
@@ -0,0 +1,24 @@
+{
+ "data": {
+ "blocks": [
+ {
+ "@type": "@builder.io/sdk:Element",
+ "@version": 2,
+ "id": "builder-abc123",
+ "component": {
+ "name": "Symbol",
+ "options": {
+ "symbol": {
+ "entry": "5a009380a7274b1388f8f1e500d2e28a",
+ "model": "symbol",
+ "ownerId": "99d964b716f94737a50de4d76134d098",
+ "content": {
+ "name": "Basic Symbol"
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/packages/core/src/__tests__/data/builder/symbol-multiple.json b/packages/core/src/__tests__/data/builder/symbol-multiple.json
new file mode 100644
index 0000000000..558863e35a
--- /dev/null
+++ b/packages/core/src/__tests__/data/builder/symbol-multiple.json
@@ -0,0 +1,72 @@
+{
+ "data": {
+ "blocks": [
+ {
+ "@type": "@builder.io/sdk:Element",
+ "@version": 2,
+ "id": "builder-jkl012",
+ "component": {
+ "name": "Symbol",
+ "options": {
+ "symbol": {
+ "entry": "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e",
+ "model": "symbol",
+ "ownerId": "99d964b716f94737a50de4d76134d098",
+ "content": {
+ "name": "Primary Button"
+ },
+ "data": {
+ "text": "Get Started",
+ "variant": "primary"
+ }
+ }
+ }
+ }
+ },
+ {
+ "@type": "@builder.io/sdk:Element",
+ "@version": 2,
+ "id": "builder-mno345",
+ "component": {
+ "name": "Symbol",
+ "options": {
+ "symbol": {
+ "entry": "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f",
+ "model": "symbol",
+ "ownerId": "99d964b716f94737a50de4d76134d098",
+ "content": {
+ "name": "Secondary Button"
+ },
+ "data": {
+ "text": "Learn More",
+ "variant": "secondary"
+ }
+ }
+ }
+ }
+ },
+ {
+ "@type": "@builder.io/sdk:Element",
+ "@version": 2,
+ "id": "builder-pqr678",
+ "component": {
+ "name": "Symbol",
+ "options": {
+ "symbol": {
+ "entry": "4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a",
+ "model": "symbol",
+ "ownerId": "99d964b716f94737a50de4d76134d098",
+ "content": {
+ "name": "Footer Section"
+ },
+ "data": {
+ "copyrightText": "© 2024 Company Name",
+ "showSocialLinks": true
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/packages/core/src/__tests__/data/builder/symbol-with-inputs.json b/packages/core/src/__tests__/data/builder/symbol-with-inputs.json
new file mode 100644
index 0000000000..126fafa0b4
--- /dev/null
+++ b/packages/core/src/__tests__/data/builder/symbol-with-inputs.json
@@ -0,0 +1,34 @@
+{
+ "data": {
+ "blocks": [
+ {
+ "@type": "@builder.io/sdk:Element",
+ "@version": 2,
+ "id": "builder-def456",
+ "component": {
+ "name": "Symbol",
+ "options": {
+ "symbol": {
+ "entry": "7b8c9d0e1a2b3c4d5e6f7a8b9c0d1e2f",
+ "model": "symbol",
+ "ownerId": "99d964b716f94737a50de4d76134d098",
+ "content": {
+ "name": "Button Component"
+ },
+ "data": {
+ "buttonText": "Click me!",
+ "variant": "primary",
+ "isDisabled": false,
+ "count": 42,
+ "config": {
+ "showIcon": true,
+ "iconPosition": "left"
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/packages/core/src/__tests__/data/builder/symbol-with-named-entry.json b/packages/core/src/__tests__/data/builder/symbol-with-named-entry.json
new file mode 100644
index 0000000000..66e018864c
--- /dev/null
+++ b/packages/core/src/__tests__/data/builder/symbol-with-named-entry.json
@@ -0,0 +1,38 @@
+{
+ "data": {
+ "blocks": [
+ {
+ "@type": "@builder.io/sdk:Element",
+ "@version": 2,
+ "id": "builder-ghi789",
+ "component": {
+ "name": "Symbol",
+ "options": {
+ "symbol": {
+ "entry": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
+ "model": "symbol",
+ "ownerId": "99d964b716f94737a50de4d76134d098",
+ "content": {
+ "name": "Header Navigation"
+ },
+ "data": {
+ "logoUrl": "/logo.png",
+ "showSearch": true,
+ "menuItems": [
+ {
+ "label": "Home",
+ "url": "/"
+ },
+ {
+ "label": "About",
+ "url": "/about"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/packages/core/src/generators/builder/generator.ts b/packages/core/src/generators/builder/generator.ts
index 2a1f21c336..06ca38424c 100644
--- a/packages/core/src/generators/builder/generator.ts
+++ b/packages/core/src/generators/builder/generator.ts
@@ -656,6 +656,54 @@ export const blockToBuilder = (
const element = mapper(json, options);
return processLocalizedValues(element, json);
}
+
+ if (json.name.startsWith('Symbol') && json.name !== 'Symbol' && json.bindings.symbol?.code) {
+ const symbolOptions = attempt(() => json5.parse(json.bindings.symbol!.code));
+
+ if (!(symbolOptions instanceof Error)) {
+ if (!symbolOptions.name) {
+ const displayName = json.name
+ .replace(/^Symbol/, '')
+ .replace(/([A-Z])/g, ' $1')
+ .trim();
+ if (displayName) {
+ symbolOptions.name = displayName;
+ }
+ }
+
+ const inputData: Record = {};
+ for (const key of Object.keys(json.bindings)) {
+ if (key !== 'symbol' && key !== 'css' && key !== 'style') {
+ const value = attempt(() => json5.parse(json.bindings[key]!.code));
+ if (!(value instanceof Error)) {
+ inputData[key] = value;
+ }
+ }
+ }
+ for (const key of Object.keys(json.properties)) {
+ if (!key.startsWith('$') && !key.startsWith('_') && !key.startsWith('data-')) {
+ inputData[key] = json.properties[key];
+ }
+ }
+ if (Object.keys(inputData).length > 0) {
+ symbolOptions.data = { ...symbolOptions.data, ...inputData };
+ }
+
+ const element = el(
+ {
+ component: {
+ name: 'Symbol',
+ options: {
+ symbol: symbolOptions,
+ },
+ },
+ },
+ options,
+ );
+ return processLocalizedValues(element, json);
+ }
+ }
+
if (json.properties._text || json.bindings._text?.code) {
const element = el(
{
diff --git a/packages/core/src/parsers/builder/builder.ts b/packages/core/src/parsers/builder/builder.ts
index bc59beb632..5457f3c448 100644
--- a/packages/core/src/parsers/builder/builder.ts
+++ b/packages/core/src/parsers/builder/builder.ts
@@ -241,6 +241,37 @@ const wrapBindingIfNeeded = (value: string, options: BuilderToMitosisOptions) =>
return value;
};
+/**
+ * Sanitizes a symbol name to be a valid JSX component name.
+ * - Converts to PascalCase
+ * - Removes invalid characters
+ * - Adds "Symbol" prefix to avoid collisions
+ * - Returns "Symbol" if no valid name can be generated
+ */
+const sanitizeSymbolName = (name: string | undefined): string => {
+ if (!name || typeof name !== 'string') {
+ return 'Symbol';
+ }
+
+ // Remove special characters and split into words
+ const words = name
+ .replace(/[^a-zA-Z0-9\s]/g, ' ')
+ .split(/\s+/)
+ .filter(Boolean);
+
+ if (words.length === 0) {
+ return 'Symbol';
+ }
+
+ // Convert to PascalCase
+ const pascalCase = words
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join('');
+
+ // Add Symbol prefix to avoid collisions with other components
+ return `Symbol${pascalCase}`;
+};
+
const getBlockActions = (block: BuilderElement, options: BuilderToMitosisOptions) => {
const obj = {
...block.actions,
@@ -330,12 +361,47 @@ const componentMappers: {
const styleString = getStyleStringFromBlock(block, options);
const actionBindings = getActionBindingsFromBlock(block, options);
+ const symbolOptions = block.component?.options?.symbol;
+ const symbolName = symbolOptions?.content?.name || symbolOptions?.name;
+ const componentName =
+ block.component?.name !== 'Symbol'
+ ? block.component?.name // Use name already set by extractSymbols
+ : sanitizeSymbolName(symbolName);
+
+ // Extract inputs from symbol.data to make them visible as top-level JSX props
+ //
+ // In Builder.io, Symbol components can receive inputs through symbol.options.data,
+ // which contains key-value pairs for props passed to the symbol instance.
+ //
+ // We extract these from the nested data structure and create individual bindings
+ // for each input so they become first-class props in the generated code
+ // (e.g., ) instead of being buried in metadata
+ // (e.g., ).
+ //
+ // This transformation enables proper prop passing and makes the component usage
+ // more idiomatic in the target framework.
+ const symbolData = symbolOptions?.data || {};
+ const inputBindings: Dictionary = {};
+ const hasInputs = Object.keys(symbolData).length > 0;
+
+ // Only extract inputs if there are any to avoid data loss
+ if (hasInputs) {
+ // Create individual bindings for each input
+ for (const key in symbolData) {
+ inputBindings[key] = createSingleBinding({
+ code: json5.stringify(symbolData[key]),
+ });
+ }
+ }
+
+ // Keep symbol metadata - only omit data if we extracted inputs
+ const symbolMetadata = hasInputs ? omit(symbolOptions, 'data') : symbolOptions;
+
const bindings: Dictionary = {
symbol: createSingleBinding({
- code: JSON.stringify({
- ...block.component?.options.symbol,
- }),
+ code: JSON.stringify(symbolMetadata),
}),
+ ...inputBindings,
...actionBindings,
...(styleString && {
style: createSingleBinding({ code: styleString }),
@@ -346,7 +412,7 @@ const componentMappers: {
};
return createMitosisNode({
- name: 'Symbol',
+ name: componentName,
bindings: bindings,
meta: getMetaFromBlock(block, options),
});
@@ -631,7 +697,11 @@ export const builderElementToMitosisNode = (
}
}
const mapper =
- !_internalOptions.skipMapper && block.component && componentMappers[block.component!.name];
+ !_internalOptions.skipMapper &&
+ block.component &&
+ (componentMappers[block.component!.name] ||
+ // Handle symbols that were renamed by extractSymbols (e.g., "SymbolButtonComponent")
+ (block.component!.name.startsWith('Symbol') ? componentMappers['Symbol'] : undefined));
if (mapper) {
return mapper(block, options);
@@ -1063,7 +1133,8 @@ function extractSymbols(json: BuilderContent) {
continue;
}
- const componentName = 'Symbol' + ++symbolsFound;
+ const symbolName = elContent.name || symbolValue?.name;
+ const componentName = sanitizeSymbolName(symbolName) || `Symbol${++symbolsFound}`;
el.component!.name = componentName;