Skip to content

Commit 63bff04

Browse files
cursoragentskovhus
andcommitted
Support theme member expressions in inline style fallback
The tryBuildThemeBooleanInlineStyleFallback guard required the unresolvable branch to contain a call expression. This rejected theme member expressions like theme.baseTheme?.color.X where the adapter can't resolve the path to a static token. Replace the hasCallExpression check with isFullyTransformedThemeExpr, which validates that replaceThemeRefsWithHookVar successfully replaced all param/theme binding references — accepting both call and member expressions. Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com>
1 parent 02babcb commit 63bff04

6 files changed

Lines changed: 73 additions & 22 deletions

File tree

src/__tests__/fixture-adapters.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@ export const fixtureAdapter = defineAdapter({
180180
} satisfies ResolveValueDirectionalResult;
181181
}
182182

183+
// Nested theme objects (e.g. theme.baseTheme?.color.X) are not resolvable
184+
// to static tokens — return undefined so the codemod falls back to runtime.
185+
if (ctx.path.startsWith("baseTheme.")) {
186+
return undefined;
187+
}
188+
183189
// Test fixtures use a small ThemeProvider theme shape:
184190
// props.theme.color.labelBase -> $colors.labelBase
185191
// props.theme.color[bg] -> $colors[bg]

src/internal/builtin-handlers/conditionals.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
type ArrowFnParamBindings,
88
type CallExpressionNode,
99
cloneAstNode,
10+
collectIdentifiers,
1011
getArrowFnParamBindings,
1112
getArrowFnSingleParamName,
1213
getFunctionBodyExpr,
@@ -1646,17 +1647,18 @@ function tryBuildThemeBooleanInlineStyleFallback(args: {
16461647
const resolvedBranchIsTrue = trueValue !== null;
16471648
const unresolvableBranch = resolvedBranchIsTrue ? falseBranch : trueBranch;
16481649

1649-
// The unresolvable branch must contain a call expression (theme helper call)
1650-
if (!hasCallExpression(unresolvableBranch)) {
1651-
return null;
1652-
}
1653-
16541650
// Transform the unresolvable branch: replace props.theme.* / <param>.theme.* with theme.*
16551651
const transformed = replaceThemeRefsWithHookVar(unresolvableBranch, paramName, info);
16561652
if (!transformed) {
16571653
return null;
16581654
}
16591655

1656+
// Verify the transformation replaced all param/theme binding references.
1657+
// Dangling references (e.g. non-theme prop accesses) would produce undefined variables at runtime.
1658+
if (!isFullyTransformedThemeExpr(transformed, paramName, info)) {
1659+
return null;
1660+
}
1661+
16601662
return {
16611663
type: "splitThemeBooleanWithInlineStyleFallback",
16621664
cssProp,
@@ -1669,25 +1671,28 @@ function tryBuildThemeBooleanInlineStyleFallback(args: {
16691671
};
16701672
}
16711673

1672-
/** Check if an expression tree contains any call expressions. */
1673-
function hasCallExpression(node: unknown): boolean {
1674-
if (!node || typeof node !== "object") {
1674+
/**
1675+
* Validates that a transformed expression has no dangling references to
1676+
* the original arrow function parameter or theme binding name.
1677+
* After `replaceThemeRefsWithHookVar`, all `<paramName>.theme.*` should
1678+
* have been rewritten to `theme.*`. If the param name still appears,
1679+
* the expression accesses non-theme props and can't be safely used
1680+
* with `useTheme()` alone.
1681+
*/
1682+
function isFullyTransformedThemeExpr(
1683+
transformed: unknown,
1684+
paramName: string | null,
1685+
info: ThemeParamInfo | null,
1686+
): boolean {
1687+
const ids = new Set<string>();
1688+
collectIdentifiers(transformed, ids);
1689+
if (paramName && ids.has(paramName)) {
16751690
return false;
16761691
}
1677-
const n = node as { type?: string };
1678-
if (n.type === "CallExpression") {
1679-
return true;
1680-
}
1681-
for (const key of Object.keys(n)) {
1682-
if (key === "loc" || key === "comments") {
1683-
continue;
1684-
}
1685-
const child = (n as Record<string, unknown>)[key];
1686-
if (child && typeof child === "object" && hasCallExpression(child)) {
1687-
return true;
1688-
}
1692+
if (info?.kind === "themeBinding" && info.themeName !== "theme" && ids.has(info.themeName)) {
1693+
return false;
16891694
}
1690-
return false;
1695+
return true;
16911696
}
16921697

16931698
/**

src/internal/lower-rules/rule-interpolated-declaration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ export function handleInterpolatedDeclaration(args: InterpolatedDeclarationConte
10491049
continue;
10501050
}
10511051

1052-
// Handle theme boolean conditional with one unresolvable call expression branch.
1052+
// Handle theme boolean conditional with one unresolvable branch (call or member expression).
10531053
// The resolved branch becomes the base StyleX style; the unresolvable branch
10541054
// is emitted as a conditional inline style using the useTheme() hook.
10551055
if (res && res.type === "splitThemeBooleanWithInlineStyleFallback") {

test-cases/theme-conditionalInlineStyle.input.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@ export const Chip = styled.div`
99
: props.theme.color.bgFocus};
1010
`;
1111

12+
// CSS custom property with one unresolvable theme member expression branch
13+
const DayPicker = styled.div`
14+
--highlighted-color: ${(p) =>
15+
p.theme.isDark ? p.theme.baseTheme?.color.bgBorderSolid : p.theme.color.bgBorderFaint};
16+
background-color: var(--highlighted-color);
17+
padding: 16px;
18+
`;
19+
1220
export const App = () => (
1321
<div style={{ display: "flex", gap: 16, padding: 16 }}>
1422
<Chip>Default</Chip>
23+
<DayPicker>DayPicker</DayPicker>
1524
</div>
1625
);

test-cases/theme-conditionalInlineStyle.output.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,30 @@ export function Chip(props: Pick<React.ComponentProps<"div">, "ref" | "children"
2222
);
2323
}
2424

25+
// CSS custom property with one unresolvable theme member expression branch
26+
function DayPicker(props: React.PropsWithChildren<{}>) {
27+
const theme = useTheme();
28+
const sx = stylex.props(styles.dayPicker);
29+
30+
return (
31+
<div
32+
{...sx}
33+
style={
34+
{
35+
...sx.style,
36+
"--highlighted-color": theme.isDark ? theme.baseTheme?.color.bgBorderSolid : undefined,
37+
} as React.CSSProperties
38+
}
39+
>
40+
{props.children}
41+
</div>
42+
);
43+
}
44+
2545
export const App = () => (
2646
<div style={{ display: "flex", gap: 16, padding: 16 }}>
2747
<Chip>Default</Chip>
48+
<DayPicker>DayPicker</DayPicker>
2849
</div>
2950
);
3051

@@ -34,4 +55,9 @@ const styles = stylex.create({
3455
paddingInline: 16,
3556
backgroundColor: $colors.bgFocus,
3657
},
58+
dayPicker: {
59+
"--highlighted-color": $colors.bgBorderFaint,
60+
backgroundColor: "var(--highlighted-color)",
61+
padding: 16,
62+
},
3763
});

test-cases/tokens.stylex.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export const testCaseTheme = {
5454
textSecondary: "#6B7280",
5555
primaryColor: "#BF4F74",
5656
},
57+
baseTheme: {
58+
color: {
59+
bgBorderSolid: "#94A3B8",
60+
},
61+
},
5762
highlightVariant,
5863
primary: "#BF4F74",
5964
secondary: "#4F74BF",

0 commit comments

Comments
 (0)