Skip to content

Commit 00740f9

Browse files
committed
Improve support for helper functions
1 parent 77fc08f commit 00740f9

20 files changed

Lines changed: 525 additions & 63 deletions

.oxlintrc.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
"array-callback-return": "error",
1919
"no-self-compare": "error"
2020
},
21+
"categories": {
22+
"correctness": "error",
23+
"suspicious": "off", // TODO: enable
24+
"perf": "off"
25+
},
2126
"ignorePatterns": [
2227
"dist/**",
2328
"node_modules/**",

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,12 @@ When the codemod encounters an interpolation inside a styled template literal, i
230230
- theme access (`props.theme...`) via `resolveValue({ kind: "theme", path })`
231231
- prop access (`props.foo`) and conditionals (`props.foo ? "a" : "b"`, `props.foo && "color: red;"`)
232232
- simple helper calls (`transitionSpeed("slowTransition")`) via `resolveValue({ kind: "call", calleeImportedName, calleeSource, args, ... })`
233+
- helper calls applied to prop values (e.g. `shadow(props.shadow)`) by emitting a StyleX style function that calls the helper at runtime
234+
- conditional CSS blocks via ternary (e.g. `props.$dim ? "opacity: 0.5;" : ""`)
233235

234236
If the pipeline can’t resolve an interpolation:
235237

236-
- for `withConfig({ shouldForwardProp })` wrappers, the transform preserves the value as an inline style so output keeps visual parity
238+
- for some dynamic value cases, the transform preserves the value as a wrapper inline style so output keeps visual parity (at the cost of using `style={...}` for that prop)
237239
- otherwise, the declaration containing that interpolation is **dropped** and a warning is produced (manual follow-up required)
238240

239241
### Limitations

src/internal/builtin-handlers.ts

Lines changed: 205 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,73 @@ export type DynamicNode = {
1717
};
1818

1919
export type HandlerResult =
20-
| { type: "resolvedValue"; expr: string; imports: ImportSpec[] }
21-
| { type: "emitInlineStyle"; style: string }
2220
| {
21+
/**
22+
* The node was resolved to a JS expression string that can be directly inlined into
23+
* generated output (typically for a single CSS property value).
24+
*
25+
* Example: `props.theme.colors.bgBase` -> `themeVars.bgBase`
26+
*
27+
* The caller is responsible for:
28+
* - parsing `expr` into an AST
29+
* - adding `imports`
30+
*/
31+
type: "resolvedValue";
32+
expr: string;
33+
imports: ImportSpec[];
34+
}
35+
| {
36+
/**
37+
* Emit a wrapper inline style from a raw CSS string snippet.
38+
*
39+
* This is intentionally narrow and primarily used for keeping runtime parity
40+
* when the codemod cannot safely lower to StyleX (e.g. complex dynamic blocks).
41+
*/
42+
type: "emitInlineStyle";
43+
style: string;
44+
}
45+
| {
46+
/**
47+
* Preserve the dynamic value by emitting a wrapper inline style:
48+
* style={{ ..., prop: expr(props) }}
49+
*
50+
* This is used for cases where we can't (or don't want to) lower into StyleX
51+
* buckets, but can safely keep parity with styled-components at runtime.
52+
*/
53+
type: "emitInlineStyleValueFromProps";
54+
}
55+
| {
56+
/**
57+
* Emit a StyleX style function keyed off a single JSX prop.
58+
*
59+
* The caller uses this to generate a helper like:
60+
* const styles = stylex.create({
61+
* boxShadowFromProp: (shadow) => ({ boxShadow: shadow })
62+
* })
63+
*
64+
* And apply it conditionally in the wrapper:
65+
* shadow != null && styles.boxShadowFromProp(shadow)
66+
*/
2367
type: "emitStyleFunction";
2468
nameHint: string;
2569
params: string;
2670
body: string;
2771
call: string;
72+
/**
73+
* Optional value transform to apply to the param before assigning to the style prop.
74+
* This allows supporting patterns like:
75+
* box-shadow: ${(props) => shadow(props.shadow)};
76+
* by emitting a style function that computes: `shadow(value)`.
77+
*/
78+
valueTransform?: { kind: "call"; calleeIdent: string };
2879
}
2980
| {
81+
/**
82+
* Split a dynamic interpolation into one or more variant buckets.
83+
*
84+
* Each variant contains a static StyleX-style object. The caller is responsible for
85+
* wiring these into `stylex.create(...)` keys and applying them under the `when` condition.
86+
*/
3087
type: "splitVariants";
3188
variants: Array<{
3289
nameHint: string;
@@ -47,7 +104,16 @@ export type HandlerResult =
47104
imports: ImportSpec[];
48105
}>;
49106
}
50-
| { type: "keepOriginal"; reason: string };
107+
| {
108+
/**
109+
* Signal that this handler does not know how to transform the node.
110+
*
111+
* The caller typically falls back to other strategies (or drops the declaration)
112+
* and may surface `reason` as a warning.
113+
*/
114+
type: "keepOriginal";
115+
reason: string;
116+
};
51117

52118
export type InternalHandlerContext = {
53119
api: API;
@@ -503,6 +569,138 @@ function tryResolveConditionalCssBlock(node: DynamicNode): HandlerResult | null
503569
return null;
504570
}
505571

572+
function tryResolveConditionalCssBlockTernary(node: DynamicNode): HandlerResult | null {
573+
const expr = node.expr;
574+
if (!isArrowFunctionExpression(expr)) {
575+
return null;
576+
}
577+
const paramName = getArrowFnSingleParamName(expr);
578+
if (!paramName) {
579+
return null;
580+
}
581+
if (expr.body.type !== "ConditionalExpression") {
582+
return null;
583+
}
584+
585+
// Support patterns like:
586+
// ${(props) => (props.$dim ? "opacity: 0.5;" : "")}
587+
const test = expr.body.test as any;
588+
if (!test || test.type !== "MemberExpression") {
589+
return null;
590+
}
591+
const testPath = getMemberPathFromIdentifier(test, paramName);
592+
if (!testPath || testPath.length !== 1) {
593+
return null;
594+
}
595+
const when = testPath[0]!;
596+
597+
const consText = literalToString(expr.body.consequent);
598+
const altText = literalToString(expr.body.alternate);
599+
if (consText === null || altText === null) {
600+
return null;
601+
}
602+
603+
const consStyle = consText.trim() ? parseCssDeclarationBlock(consText) : null;
604+
const altStyle = altText.trim() ? parseCssDeclarationBlock(altText) : null;
605+
if (!consStyle && !altStyle) {
606+
return null;
607+
}
608+
609+
const variants: Array<{ nameHint: string; when: string; style: Record<string, unknown> }> = [];
610+
if (altStyle) {
611+
variants.push({ nameHint: "falsy", when: `!${when}`, style: altStyle });
612+
}
613+
if (consStyle) {
614+
variants.push({ nameHint: "truthy", when, style: consStyle });
615+
}
616+
return { type: "splitVariants", variants };
617+
}
618+
619+
function tryResolveArrowFnCallWithSinglePropArg(node: DynamicNode): HandlerResult | null {
620+
if (!node.css.property) {
621+
return null;
622+
}
623+
const expr = node.expr as any;
624+
if (!isArrowFunctionExpression(expr)) {
625+
return null;
626+
}
627+
const paramName = getArrowFnSingleParamName(expr);
628+
if (!paramName) {
629+
return null;
630+
}
631+
const body = expr.body as any;
632+
if (!body || body.type !== "CallExpression") {
633+
return null;
634+
}
635+
// Only support: helper(props.foo)
636+
if (body.callee?.type !== "Identifier" || typeof body.callee.name !== "string") {
637+
return null;
638+
}
639+
const calleeIdent = body.callee.name as string;
640+
const args = body.arguments ?? [];
641+
if (args.length !== 1) {
642+
return null;
643+
}
644+
const arg0 = args[0] as any;
645+
if (!arg0 || arg0.type !== "MemberExpression") {
646+
return null;
647+
}
648+
const path = getMemberPathFromIdentifier(arg0, paramName);
649+
if (!path || path.length !== 1) {
650+
return null;
651+
}
652+
const propName = path[0]!;
653+
654+
return {
655+
type: "emitStyleFunction",
656+
nameHint: `${sanitizeIdentifier(node.css.property)}FromProp`,
657+
params: "value: any",
658+
body: `{ ${Object.keys(styleFromSingleDeclaration(node.css.property, "value"))[0]}: value }`,
659+
call: propName,
660+
valueTransform: { kind: "call", calleeIdent },
661+
};
662+
}
663+
664+
function tryResolveInlineStyleValueForConditionalExpression(
665+
node: DynamicNode,
666+
): HandlerResult | null {
667+
// Conservative fallback for value expressions we can't safely resolve into StyleX
668+
// buckets/functions, but can preserve via a wrapper inline style.
669+
if (!node.css.property) {
670+
return null;
671+
}
672+
const expr: any = node.expr as any;
673+
if (!isArrowFunctionExpression(expr)) {
674+
return null;
675+
}
676+
if (expr.body?.type !== "ConditionalExpression") {
677+
return null;
678+
}
679+
// IMPORTANT: do not attempt to preserve `props.theme.* ? ... : ...` via inline styles.
680+
// StyleX output does not have `props.theme` at runtime (styled-components injects theme via context),
681+
// so this would produce incorrect output unless a project-specific hook (e.g. useTheme()) is wired in.
682+
//
683+
// Treat these as unsupported so the caller can bail and surface a warning.
684+
{
685+
const paramName = getArrowFnSingleParamName(expr);
686+
const test = expr.body.test as any;
687+
const testPath =
688+
paramName && test?.type === "MemberExpression"
689+
? getMemberPathFromIdentifier(test, paramName)
690+
: null;
691+
if (testPath && testPath[0] === "theme") {
692+
return {
693+
type: "keepOriginal",
694+
reason:
695+
"Theme-dependent conditional values require a project-specific theme source (e.g. useTheme()); cannot safely preserve.",
696+
};
697+
}
698+
}
699+
// Signal to the caller that we can preserve this declaration as an inline style
700+
// by calling the function with `props`.
701+
return { type: "emitInlineStyleValueFromProps" };
702+
}
703+
506704
function tryResolvePropAccess(node: DynamicNode): HandlerResult | null {
507705
if (!node.css.property) {
508706
return null;
@@ -549,8 +747,11 @@ export function resolveDynamicNode(
549747
tryResolveThemeAccess(node, ctx) ??
550748
tryResolveCallExpression(node, ctx) ??
551749
tryResolveConditionalValue(node, ctx) ??
750+
tryResolveConditionalCssBlockTernary(node) ??
552751
tryResolveConditionalCssBlock(node) ??
553-
tryResolvePropAccess(node)
752+
tryResolveArrowFnCallWithSinglePropArg(node) ??
753+
tryResolvePropAccess(node) ??
754+
tryResolveInlineStyleValueForConditionalExpression(node)
554755
);
555756
}
556757

src/internal/collect-styled-decls.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,31 @@ function collectStyledDeclsImpl(args: {
181181
return out;
182182
};
183183

184+
const shouldForceWrapperForAttrs = (attrsInfo: StyledDecl["attrsInfo"] | undefined): boolean => {
185+
if (!attrsInfo) {
186+
return false;
187+
}
188+
const props = new Set<string>();
189+
for (const c of attrsInfo.conditionalAttrs ?? []) {
190+
if (typeof c?.jsxProp === "string") {
191+
props.add(c.jsxProp);
192+
}
193+
}
194+
for (const a of attrsInfo.defaultAttrs ?? []) {
195+
if (typeof a?.jsxProp === "string") {
196+
props.add(a.jsxProp);
197+
}
198+
}
199+
for (const inv of attrsInfo.invertedBoolAttrs ?? []) {
200+
if (typeof inv?.jsxProp === "string") {
201+
props.add(inv.jsxProp);
202+
}
203+
}
204+
// If attrs depend on transient props ($...), emit a wrapper so we can consume those props
205+
// (and avoid forwarding them to the DOM) without trying to specialize per callsite.
206+
return [...props].some((p) => p.startsWith("$"));
207+
};
208+
184209
const parseShouldForwardProp = (arg0: any): StyledDecl["shouldForwardProp"] | undefined => {
185210
if (!arg0 || arg0.type !== "ObjectExpression") {
186211
return undefined;
@@ -612,6 +637,7 @@ function collectStyledDeclsImpl(args: {
612637
templateExpressions: parsed.slots.map((s) => s.expression),
613638
rawCss: parsed.rawCss,
614639
...(attrsInfo ? { attrsInfo } : {}),
640+
...(shouldForceWrapperForAttrs(attrsInfo) ? { needsWrapperComponent: true } : {}),
615641
...(shouldForwardProp ? { shouldForwardProp } : {}),
616642
...(shouldForwardProp ? { shouldForwardPropFromWithConfig: true } : {}),
617643
...(withConfigMeta ? { withConfig: withConfigMeta } : {}),
@@ -657,6 +683,7 @@ function collectStyledDeclsImpl(args: {
657683
templateExpressions: parsed.slots.map((s) => s.expression),
658684
rawCss: parsed.rawCss,
659685
...(attrsInfo ? { attrsInfo } : {}),
686+
...(shouldForceWrapperForAttrs(attrsInfo) ? { needsWrapperComponent: true } : {}),
660687
...(propsType ? { propsType } : {}),
661688
...(leadingComments ? { leadingComments } : {}),
662689
});
@@ -697,6 +724,7 @@ function collectStyledDeclsImpl(args: {
697724
templateExpressions: parsed.slots.map((s) => s.expression),
698725
rawCss: parsed.rawCss,
699726
...(attrsInfo ? { attrsInfo } : {}),
727+
...(shouldForceWrapperForAttrs(attrsInfo) ? { needsWrapperComponent: true } : {}),
700728
...(propsType ? { propsType } : {}),
701729
...(leadingComments ? { leadingComments } : {}),
702730
});

src/internal/emit-wrappers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ function emitWrappersImpl(args: {
844844
case "div":
845845
return "React.HTMLAttributes<HTMLDivElement>";
846846
case "input":
847-
return "React.InputHTMLAttributes<HTMLInputElement>";
847+
return 'React.ComponentProps<"input">';
848848
case "img":
849849
return "React.ImgHTMLAttributes<HTMLImageElement>";
850850
case "label":

0 commit comments

Comments
 (0)