Skip to content

Commit 39f758d

Browse files
committed
Improve the general case
1 parent a1789e3 commit 39f758d

17 files changed

Lines changed: 412 additions & 238 deletions

.oxlintrc.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,19 @@
44
"no-unused-vars": "error",
55
"no-console": "error",
66
"eqeqeq": "error",
7-
"curly": "error"
7+
"curly": "error",
8+
"no-constant-condition": "error",
9+
"no-debugger": "error",
10+
"no-duplicate-case": "error",
11+
"no-empty": "error",
12+
"no-ex-assign": "error",
13+
"no-extra-boolean-cast": "error",
14+
"no-irregular-whitespace": "error",
15+
"no-sparse-arrays": "error",
16+
"use-isnan": "error",
17+
"valid-typeof": "error",
18+
"array-callback-return": "error",
19+
"no-self-compare": "error"
820
},
921
"ignorePatterns": [
1022
"dist/**",

src/__tests__/fixture-adapters.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export const customAdapter = defineAdapter({
2020

2121
// Fixtures don't use theme resolution, but the transformer requires an adapter.
2222
export const fixtureAdapter = defineAdapter({
23+
// Enable external styles for exported components in external-styles-support test case
24+
shouldSupportExternalStyles(ctx) {
25+
return (
26+
ctx.filePath.includes("external-styles-support") && ctx.componentName === "ExportedButton"
27+
);
28+
},
29+
2330
resolveValue(ctx) {
2431
if (ctx.kind === "theme") {
2532
// Test fixtures use a small ThemeProvider theme shape:

src/adapter.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,35 @@ export type ImportSource =
6363

6464
export type ImportSpec = { from: ImportSource; names: Array<{ imported: string; local?: string }> };
6565

66+
// ────────────────────────────────────────────────────────────────────────────
67+
// External Styles Context
68+
// ────────────────────────────────────────────────────────────────────────────
69+
70+
export interface ExternalStylesContext {
71+
/** Absolute path of the file being transformed */
72+
filePath: string;
73+
/** Local name of the styled component */
74+
componentName: string;
75+
/** The export name (may differ from componentName for renamed exports) */
76+
exportName: string;
77+
/** Whether it's a default export */
78+
isDefaultExport: boolean;
79+
}
80+
6681
// ────────────────────────────────────────────────────────────────────────────
6782
// Adapter Interface
6883
// ────────────────────────────────────────────────────────────────────────────
6984

7085
export interface Adapter {
7186
/** Unified resolver for theme paths + CSS variables. Return null to leave unresolved. */
7287
resolveValue: (context: ResolveContext) => ResolveResult | null;
88+
89+
/**
90+
* Called for exported styled components to determine if they should support
91+
* external className/style extension. Return true to generate wrapper with
92+
* className/style/rest merging. Default: false.
93+
*/
94+
shouldSupportExternalStyles?: (context: ExternalStylesContext) => boolean;
7395
}
7496

7597
// ────────────────────────────────────────────────────────────────────────────
@@ -92,6 +114,12 @@ export interface Adapter {
92114
* }
93115
* return null;
94116
* },
117+
*
118+
* // Optional: Enable className/style/rest support for exported components
119+
* shouldSupportExternalStyles(ctx) {
120+
* // Example: Enable for all exported components in a shared components folder
121+
* return ctx.filePath.includes("/shared/components/");
122+
* },
95123
* });
96124
*/
97125
export function defineAdapter(adapter: Adapter): Adapter {

src/internal/css-prop-mapping.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import type { CssDeclarationIR, CssValue } from "./css-ir.js";
22

33
export type StylexPropDecl = { prop: string; value: CssValue };
44

5+
const BORDER_STYLES = new Set([
6+
"none",
7+
"solid",
8+
"dashed",
9+
"dotted",
10+
"double",
11+
"groove",
12+
"ridge",
13+
"inset",
14+
"outset",
15+
]);
16+
517
export function cssDeclarationToStylexDeclarations(decl: CssDeclarationIR): StylexPropDecl[] {
618
const prop = decl.property.trim();
719

@@ -37,17 +49,6 @@ function borderShorthandToStylex(valueRaw: string): StylexPropDecl[] {
3749
}
3850

3951
const tokens = v.split(/\s+/);
40-
const borderStyles = new Set([
41-
"none",
42-
"solid",
43-
"dashed",
44-
"dotted",
45-
"double",
46-
"groove",
47-
"ridge",
48-
"inset",
49-
"outset",
50-
]);
5152

5253
let width: string | undefined;
5354
let style: string | undefined;
@@ -58,7 +59,7 @@ function borderShorthandToStylex(valueRaw: string): StylexPropDecl[] {
5859
width = token;
5960
continue;
6061
}
61-
if (!style && borderStyles.has(token)) {
62+
if (!style && BORDER_STYLES.has(token)) {
6263
style = token;
6364
continue;
6465
}
@@ -83,5 +84,5 @@ function borderShorthandToStylex(valueRaw: string): StylexPropDecl[] {
8384
}
8485

8586
function looksLikeLength(token: string): boolean {
86-
return /^-?\d*\.?\d+(px|rem|em|vh|vw|vmin|vmax|%)?$/.test(token);
87+
return /^-?\d*\.?\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|lh|svh|svw|dvh|dvw|cqw|cqh|%)?$/.test(token);
8788
}

src/internal/emit-wrappers.ts

Lines changed: 129 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,100 @@
11
import type { Collection } from "jscodeshift";
22
import type { StyledDecl } from "./transform-types.js";
33

4+
// Void HTML tags that don't have children
5+
const VOID_TAGS = new Set([
6+
"area",
7+
"base",
8+
"br",
9+
"col",
10+
"embed",
11+
"hr",
12+
"img",
13+
"input",
14+
"link",
15+
"meta",
16+
"param",
17+
"source",
18+
"track",
19+
"wbr",
20+
]);
21+
22+
/**
23+
* Generates a minimal wrapper component that only destructures the necessary props
24+
* and applies stylex.props() directly without className/style/rest merging.
25+
* Uses props.children directly instead of destructuring it.
26+
*/
27+
function emitMinimalWrapper(args: {
28+
j: any;
29+
localName: string;
30+
tagName: string;
31+
styleArgs: any[];
32+
destructureProps: string[];
33+
displayName: string | undefined;
34+
patternProp: (keyName: string, valueId?: any) => any;
35+
}): any[] {
36+
const { j, localName, tagName, styleArgs, destructureProps, displayName, patternProp } = args;
37+
const isVoidTag = VOID_TAGS.has(tagName);
38+
const propsId = j.identifier("props");
39+
40+
// Build destructure pattern for dynamic props only (not children)
41+
const patternProps: any[] = destructureProps.filter(Boolean).map((name) => patternProp(name));
42+
43+
const stylexPropsCall = j.callExpression(
44+
j.memberExpression(j.identifier("stylex"), j.identifier("props")),
45+
styleArgs,
46+
);
47+
48+
const openingEl = j.jsxOpeningElement(
49+
j.jsxIdentifier(tagName),
50+
[j.jsxSpreadAttribute(stylexPropsCall)],
51+
false,
52+
);
53+
54+
// Use props.children directly
55+
const propsChildren = j.memberExpression(propsId, j.identifier("children"));
56+
57+
const jsx = isVoidTag
58+
? ({
59+
type: "JSXElement",
60+
openingElement: { ...openingEl, selfClosing: true },
61+
closingElement: null,
62+
children: [],
63+
} as any)
64+
: j.jsxElement(openingEl, j.jsxClosingElement(j.jsxIdentifier(tagName)), [
65+
j.jsxExpressionContainer(propsChildren),
66+
]);
67+
68+
// Only emit destructure statement if there are props to destructure
69+
const bodyStmts: any[] = [];
70+
if (patternProps.length > 0) {
71+
bodyStmts.push(
72+
j.variableDeclaration("const", [
73+
j.variableDeclarator(j.objectPattern(patternProps as any), propsId),
74+
]),
75+
);
76+
}
77+
bodyStmts.push(j.returnStatement(jsx as any));
78+
79+
const result: any[] = [
80+
j.functionDeclaration(j.identifier(localName), [propsId], j.blockStatement(bodyStmts)),
81+
];
82+
83+
if (displayName) {
84+
result.push(
85+
j.expressionStatement(
86+
j.assignmentExpression(
87+
"=",
88+
j.memberExpression(j.identifier(localName), j.identifier("displayName")),
89+
j.literal(displayName),
90+
),
91+
),
92+
);
93+
}
94+
95+
return result;
96+
}
97+
498
export function emitWrappers(args: {
599
root: Collection<any>;
6100
j: any;
@@ -300,6 +394,7 @@ export function emitWrappers(args: {
300394
continue;
301395
}
302396
const tagName = d.base.tagName;
397+
const supportsExternalStyles = d.supportsExternalStyles ?? false;
303398

304399
// Build style arguments: base + extends + dynamic variants (as conditional expressions).
305400
const styleArgs: any[] = [
@@ -392,6 +487,22 @@ export function emitWrappers(args: {
392487
const omitRestSpreadForTransientProps =
393488
!dropPrefix && dropProps.length > 0 && dropProps.every((p) => p.startsWith("$"));
394489

490+
// When supportsExternalStyles is false, generate minimal wrapper without className/style/rest merging
491+
if (!supportsExternalStyles) {
492+
emitted.push(
493+
...emitMinimalWrapper({
494+
j,
495+
localName: d.localName,
496+
tagName,
497+
styleArgs,
498+
destructureProps: destructureParts,
499+
displayName: d.withConfig?.displayName,
500+
patternProp,
501+
}),
502+
);
503+
continue;
504+
}
505+
395506
const patternProps: any[] = [
396507
patternProp("className", classNameId),
397508
// Pull out `children` for non-void elements so we don't forward it as an attribute.
@@ -565,6 +676,7 @@ export function emitWrappers(args: {
565676
}
566677
const tagName = d.base.tagName;
567678
const displayName = d.withConfig?.displayName;
679+
const supportsExternalStyles = d.supportsExternalStyles ?? false;
568680
const styleArgs: any[] = [
569681
...(d.extendsStyleKey
570682
? [j.memberExpression(j.identifier("styles"), j.identifier(d.extendsStyleKey))]
@@ -578,23 +690,23 @@ export function emitWrappers(args: {
578690
const styleId = j.identifier("style");
579691
const restId = j.identifier("rest");
580692

581-
const voidTags = new Set([
582-
"area",
583-
"base",
584-
"br",
585-
"col",
586-
"embed",
587-
"hr",
588-
"img",
589-
"input",
590-
"link",
591-
"meta",
592-
"param",
593-
"source",
594-
"track",
595-
"wbr",
596-
]);
597-
const isVoidTag = voidTags.has(tagName);
693+
const isVoidTag = VOID_TAGS.has(tagName);
694+
695+
// When supportsExternalStyles is false, generate minimal wrapper
696+
if (!supportsExternalStyles) {
697+
emitted.push(
698+
...emitMinimalWrapper({
699+
j,
700+
localName: d.localName,
701+
tagName,
702+
styleArgs,
703+
destructureProps: [],
704+
displayName,
705+
patternProp,
706+
}),
707+
);
708+
continue;
709+
}
598710

599711
const patternProps: any[] = [
600712
patternProp("className", classNameId),

src/internal/policy.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,6 @@ export function shouldSkipForCreateGlobalStyle(args: {
126126
});
127127
}
128128

129-
export function shouldSkipForStyledCssImport(args: {
130-
styledImports: Collection<any>;
131-
j: any;
132-
}): boolean {
133-
return !!findStyledComponentsNamedImport({
134-
styledImports: args.styledImports,
135-
j: args.j,
136-
importedName: "css",
137-
});
138-
}
139-
140129
export function universalSelectorUnsupportedWarning(
141130
loc?: { line: number; column: number } | null,
142131
): TransformWarning {

src/internal/transform-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export type StyledDecl = {
5050
extendsStyleKey?: string;
5151
variantStyleKeys?: Record<string, string>; // conditionProp -> styleKey
5252
needsWrapperComponent?: boolean;
53+
/**
54+
* Whether this component should support external className/style extension.
55+
* True if: (1) extended by another styled component, or (2) exported and adapter opts-in.
56+
*/
57+
supportsExternalStyles?: boolean;
5358
styleFnFromProps?: Array<{ fnKey: string; jsxProp: string }>;
5459
shouldForwardProp?: { dropProps: string[]; dropPrefix?: string };
5560
withConfig?: { displayName?: string; componentId?: string };

0 commit comments

Comments
 (0)