Skip to content

Commit d47be5a

Browse files
committed
Fix wrong export {} emit
1 parent d3acf04 commit d47be5a

6 files changed

Lines changed: 258 additions & 13 deletions

File tree

src/__tests__/transform.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,29 @@ describe("test case exports", () => {
206206
});
207207

208208
describe("output invariants", () => {
209-
it.each(fixtureCases)("$outputFile should not import styled-components", ({ name }) => {
210-
const { output } = readTestCase(name);
211-
expect(output).not.toMatch(/from\s+['"]styled-components['"]/);
212-
});
209+
it.each(fixtureCases)(
210+
"$outputFile should not import styled/css/keyframes from styled-components",
211+
({ name }) => {
212+
const { output } = readTestCase(name);
213+
// Allow imports of useTheme, withTheme, ThemeProvider etc. that aren't transformed
214+
// But disallow imports of styled, css, keyframes, createGlobalStyle
215+
const disallowedImports = ["styled", "css", "keyframes", "createGlobalStyle"];
216+
const importMatch = output.match(
217+
/import\s+(?:{([^}]+)}|(\w+))\s+from\s+['"]styled-components['"]/,
218+
);
219+
if (importMatch) {
220+
const namedImports = importMatch[1] || "";
221+
const defaultImport = importMatch[2] || "";
222+
const importedNames = [
223+
defaultImport,
224+
...namedImports.split(",").map((s) => s.trim().split(/\s+as\s+/)[0]),
225+
].filter(Boolean);
226+
for (const imp of importedNames) {
227+
expect(disallowedImports).not.toContain(imp);
228+
}
229+
}
230+
},
231+
);
213232
});
214233

215234
describe("fixture warning expectations", () => {

src/internal/collect-styled-decls.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,48 @@ export function collectStyledDecls(args: {
541541
...(leadingComments ? { leadingComments } : {}),
542542
});
543543
}
544+
545+
// styled("div").withConfig(...)`...` - intrinsic element with string argument
546+
if (
547+
tag.type === "CallExpression" &&
548+
tag.callee.type === "MemberExpression" &&
549+
tag.callee.property.type === "Identifier" &&
550+
tag.callee.property.name === "withConfig" &&
551+
tag.callee.object.type === "CallExpression" &&
552+
tag.callee.object.callee.type === "Identifier" &&
553+
tag.callee.object.callee.name === styledDefaultImport &&
554+
tag.callee.object.arguments.length === 1 &&
555+
(tag.callee.object.arguments[0]?.type === "StringLiteral" ||
556+
(tag.callee.object.arguments[0]?.type === "Literal" &&
557+
typeof (tag.callee.object.arguments[0] as any).value === "string"))
558+
) {
559+
const localName = id.name;
560+
const arg0 = tag.callee.object.arguments[0] as any;
561+
const tagName = arg0.type === "StringLiteral" ? arg0.value : arg0.value;
562+
const template = init.quasi;
563+
const parsed = parseStyledTemplateLiteral(template);
564+
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
565+
rawCss: parsed.rawCss,
566+
});
567+
if (hasUniversalSelectorInRules(rules)) {
568+
noteUniversalSelector(template);
569+
}
570+
const shouldForwardProp = parseShouldForwardProp(tag.arguments[0]);
571+
const withConfigMeta = parseWithConfigMeta(tag.arguments[0]);
572+
573+
styledDecls.push({
574+
...placementHints,
575+
localName,
576+
base: { kind: "intrinsic", tagName },
577+
styleKey: toStyleKey(localName),
578+
rules,
579+
templateExpressions: parsed.slots.map((s) => s.expression),
580+
rawCss: parsed.rawCss,
581+
...(shouldForwardProp ? { shouldForwardProp } : {}),
582+
...(withConfigMeta ? { withConfig: withConfigMeta } : {}),
583+
...(leadingComments ? { leadingComments } : {}),
584+
});
585+
}
544586
});
545587

546588
// Collect: const X = styled.div({ ... }) / styled.div((props) => ({ ... }))

src/internal/emit-styles.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,60 @@ export function emitStylesAndImports(args: {
5454
addHeaderComments((n as any)?.comments);
5555
}
5656

57-
// Remove styled-components import(s)
57+
// Remove styled-components import(s), but preserve any named imports that are still referenced
58+
// (e.g. useTheme, withTheme, ThemeProvider if they're still used in the code)
59+
const preservedSpecifiers: string[] = [];
60+
for (const importNode of styledImports.nodes()) {
61+
const specifiers = (importNode as any).specifiers ?? [];
62+
for (const spec of specifiers) {
63+
// Skip default import (styled) and namespace imports
64+
if (spec.type !== "ImportSpecifier") {
65+
continue;
66+
}
67+
const localName = spec.local?.name ?? spec.imported?.name;
68+
if (!localName) {
69+
continue;
70+
}
71+
// Check if this import is still referenced elsewhere in the code
72+
// Skip common styled-components exports that are being transformed away
73+
const transformedAway = ["styled", "css", "keyframes", "createGlobalStyle"];
74+
if (transformedAway.includes(localName)) {
75+
continue;
76+
}
77+
// Check if the identifier is used anywhere in the code
78+
const usages = root.find(j.Identifier, { name: localName });
79+
// Filter out usages that are just the import specifier itself
80+
const realUsages = usages.filter((p: any) => {
81+
const parent = p.parent?.node;
82+
return !(parent?.type === "ImportSpecifier");
83+
});
84+
if (realUsages.size() > 0) {
85+
preservedSpecifiers.push(localName);
86+
}
87+
}
88+
}
89+
90+
// Remove styled-components imports
5891
styledImports.remove();
5992

93+
// Re-add preserved imports from styled-components if any
94+
if (preservedSpecifiers.length > 0) {
95+
const preservedImport = j.importDeclaration(
96+
preservedSpecifiers.map((name) => j.importSpecifier(j.identifier(name))),
97+
j.literal("styled-components"),
98+
);
99+
// Insert after stylex import
100+
const body = root.get().node.program.body as any[];
101+
const stylexIdx = body.findIndex(
102+
(s) => s?.type === "ImportDeclaration" && (s.source as any)?.value === "@stylexjs/stylex",
103+
);
104+
if (stylexIdx >= 0) {
105+
body.splice(stylexIdx + 1, 0, preservedImport);
106+
} else {
107+
body.unshift(preservedImport);
108+
}
109+
}
110+
60111
// Insert stylex import at top (after existing imports, before code)
61112
const hasStylexImport =
62113
root.find(j.ImportDeclaration, { source: { value: "@stylexjs/stylex" } } as any).size() > 0;

src/internal/emit-wrappers.ts

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,17 @@ function emitMinimalWrapper(args: {
9595
return result;
9696
}
9797

98+
type ExportInfo = { exportName: string; isDefault: boolean };
99+
98100
export function emitWrappers(args: {
99101
root: Collection<any>;
100102
j: any;
101103
styledDecls: StyledDecl[];
102104
wrapperNames: Set<string>;
103105
patternProp: (keyName: string, valueId?: any) => any;
106+
exportedComponents: Map<string, ExportInfo>;
104107
}): void {
105-
const { root, j, styledDecls, wrapperNames, patternProp } = args;
108+
const { root, j, styledDecls, wrapperNames, patternProp, exportedComponents } = args;
106109

107110
const wrapperDecls = styledDecls.filter((d) => d.needsWrapperComponent);
108111
if (wrapperDecls.length === 0) {
@@ -886,6 +889,105 @@ export function emitWrappers(args: {
886889
);
887890
}
888891

892+
// Simple exported styled components (styled.div without special features)
893+
// These are exported components that need wrapper generation to maintain exports.
894+
const simpleExportedIntrinsicWrappers = wrapperDecls.filter((d) => {
895+
if (d.base.kind !== "intrinsic") {
896+
return false;
897+
}
898+
// Skip if already handled by other wrapper categories
899+
if (d.withConfig?.displayName || d.withConfig?.componentId) {
900+
return false;
901+
}
902+
if (d.shouldForwardProp) {
903+
return false;
904+
}
905+
if (d.enumVariant) {
906+
return false;
907+
}
908+
if (d.siblingWrapper) {
909+
return false;
910+
}
911+
if (d.attrWrapper) {
912+
return false;
913+
}
914+
const tagName = d.base.tagName;
915+
// Skip specialized wrapper categories
916+
if (tagName === "button" && wrapperNames.has(d.localName)) {
917+
return false;
918+
}
919+
if (tagName === "input" || tagName === "a") {
920+
return false;
921+
}
922+
return true;
923+
});
924+
925+
for (const d of simpleExportedIntrinsicWrappers) {
926+
if (d.base.kind !== "intrinsic") {
927+
continue;
928+
}
929+
const tagName = d.base.tagName;
930+
const styleArgs: any[] = [
931+
...(d.extendsStyleKey
932+
? [j.memberExpression(j.identifier("styles"), j.identifier(d.extendsStyleKey))]
933+
: []),
934+
j.memberExpression(j.identifier("styles"), j.identifier(d.styleKey)),
935+
];
936+
937+
emitted.push(
938+
...emitMinimalWrapper({
939+
j,
940+
localName: d.localName,
941+
tagName,
942+
styleArgs,
943+
destructureProps: [],
944+
displayName: undefined,
945+
patternProp,
946+
}),
947+
);
948+
}
949+
950+
// Component wrappers (styled(Component)) - these wrap another component
951+
const componentWrappers = wrapperDecls.filter((d) => d.base.kind === "component");
952+
953+
for (const d of componentWrappers) {
954+
if (d.base.kind !== "component") {
955+
continue;
956+
}
957+
const wrappedComponent = d.base.ident;
958+
const styleArgs: any[] = [
959+
...(d.extendsStyleKey
960+
? [j.memberExpression(j.identifier("styles"), j.identifier(d.extendsStyleKey))]
961+
: []),
962+
j.memberExpression(j.identifier("styles"), j.identifier(d.styleKey)),
963+
];
964+
965+
const propsId = j.identifier("props");
966+
const stylexPropsCall = j.callExpression(
967+
j.memberExpression(j.identifier("stylex"), j.identifier("props")),
968+
styleArgs,
969+
);
970+
971+
// Create: <WrappedComponent {...props} {...stylex.props(styles.key)} />
972+
const jsx = j.jsxElement(
973+
j.jsxOpeningElement(
974+
j.jsxIdentifier(wrappedComponent),
975+
[j.jsxSpreadAttribute(propsId), j.jsxSpreadAttribute(stylexPropsCall)],
976+
true,
977+
),
978+
null,
979+
[],
980+
);
981+
982+
emitted.push(
983+
j.functionDeclaration(
984+
j.identifier(d.localName),
985+
[propsId],
986+
j.blockStatement([j.returnStatement(jsx as any)]),
987+
),
988+
);
989+
}
990+
889991
if (emitted.length > 0) {
890992
// Re-order emitted wrapper nodes to match `wrapperDecls` source order.
891993
const groups = new Map<string, any[]>();
@@ -950,6 +1052,27 @@ export function emitWrappers(args: {
9501052
}
9511053
ordered.push(...restNodes);
9521054

1055+
// Wrap function declarations in export statements for exported components
1056+
const wrappedOrdered = ordered.map((node) => {
1057+
if (node?.type !== "FunctionDeclaration") {
1058+
return node;
1059+
}
1060+
const fnName = node.id?.name;
1061+
if (!fnName) {
1062+
return node;
1063+
}
1064+
const exportInfo = exportedComponents.get(fnName);
1065+
if (!exportInfo) {
1066+
return node;
1067+
}
1068+
if (exportInfo.isDefault) {
1069+
// Create: export default function X(...) { ... }
1070+
return j.exportDefaultDeclaration(node);
1071+
}
1072+
// Create: export function X(...) { ... }
1073+
return j.exportNamedDeclaration(node, [], null);
1074+
});
1075+
9531076
root
9541077
.find(j.VariableDeclaration)
9551078
.filter((p: any) =>
@@ -958,7 +1081,7 @@ export function emitWrappers(args: {
9581081
),
9591082
)
9601083
.at(0)
961-
.insertAfter(ordered);
1084+
.insertAfter(wrappedOrdered);
9621085
}
9631086

9641087
if (forceReactImport) {

src/transform.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,12 @@ export function transformWithWarnings(
878878
decl.needsWrapperComponent = true;
879879
}
880880

881+
// Exported styled components need wrapper components to maintain the export.
882+
// Without this, removing the styled declaration would leave an empty `export {}`.
883+
if (exportedComponents.has(decl.localName)) {
884+
decl.needsWrapperComponent = true;
885+
}
886+
881887
// Remove variable declarator for styled component
882888
root
883889
.find(j.VariableDeclaration)
@@ -891,7 +897,14 @@ export function transformWithWarnings(
891897
)
892898
.forEach((p) => {
893899
if (p.node.declarations.length === 1) {
894-
j(p).remove();
900+
// Check if this is inside an ExportNamedDeclaration
901+
const parent = p.parentPath;
902+
if (parent && parent.node?.type === "ExportNamedDeclaration") {
903+
// Remove the entire export declaration
904+
j(parent).remove();
905+
} else {
906+
j(p).remove();
907+
}
895908
return;
896909
}
897910
p.node.declarations = p.node.declarations.filter(
@@ -1252,6 +1265,7 @@ export function transformWithWarnings(
12521265
styledDecls,
12531266
wrapperNames,
12541267
patternProp,
1268+
exportedComponents,
12551269
});
12561270

12571271
const post = postProcessTransformedAst({

test-cases/external-styles-support.output.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
import * as stylex from "@stylexjs/stylex";
22

3-
// This component is exported and will use shouldSupportExternalStyles to enable
4-
// className/style/rest merging for external style extension support.
5-
export {};
6-
73
const styles = stylex.create({
84
exportedButton: {
95
backgroundColor: "#bf4f74",
@@ -21,7 +17,7 @@ const styles = stylex.create({
2117
},
2218
});
2319

24-
function ExportedButton(props) {
20+
export function ExportedButton(props) {
2521
const { className, children, style, ...rest } = props;
2622

2723
const sx = stylex.props(styles.exportedButton);

0 commit comments

Comments
 (0)