Skip to content

Commit 53ca185

Browse files
cursoragentskovhus
andcommitted
Resolve interpolated CSS-variable property names from local consts
Co-authored-by: Kenneth Skovhus <skovhus@users.noreply.github.com>
1 parent 8499809 commit 53ca185

4 files changed

Lines changed: 167 additions & 24 deletions

src/internal/lower-rules/process-rule-declarations.ts

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { cssDeclarationToStylexDeclarations } from "../css-prop-mapping.js";
88
import { cssValueToJs, normalizeCssContentValue } from "../transform/helpers.js";
99
import { cssKeyframeNameToIdentifier, expandStaticAnimationShorthand } from "../keyframes.js";
1010
import { handleInterpolatedDeclaration } from "./rule-interpolated-declaration.js";
11+
import { PLACEHOLDER_RE } from "../styled-css.js";
12+
import { isIdentifierNode, literalToStaticValue } from "../utilities/jscodeshift-utils.js";
1113

1214
type CommentSource = { leading?: string; trailingLine?: string } | null;
1315

@@ -37,12 +39,19 @@ export function processRuleDeclarations(args: RuleDeclarationContext): void {
3739

3840
for (const d of rule.declarations) {
3941
// Dynamic property names (slot placeholders in property position) such as
40-
// `${CSS_VAR}: 100%;` cannot be safely lowered to StyleX. Bail with a clear
41-
// warning instead of emitting a broken style entry whose key is the raw
42-
// placeholder text (e.g. `__SC_EXPR_0__`).
42+
// `${CSS_VAR}: 100%;`. Try to resolve every placeholder in the property
43+
// name to a static string (e.g. via a top-level `const X = "--var"`). If
44+
// every slot resolves to a CSS-variable-compatible literal, substitute the
45+
// resolved name and continue processing as a regular declaration. Bail
46+
// otherwise — emitting the raw `__SC_EXPR_N__` placeholder produces broken
47+
// StyleX output.
4348
if (d.property && d.property.includes("__SC_EXPR_")) {
44-
ctx.state.bailUnsupported(ctx.decl, "Unsupported interpolation: property");
45-
break;
49+
const resolvedProperty = resolveInterpolatedPropertyName(d.property, ctx);
50+
if (resolvedProperty === null) {
51+
ctx.state.bailUnsupported(ctx.decl, "Unsupported interpolation: property");
52+
break;
53+
}
54+
d.property = resolvedProperty;
4655
}
4756

4857
if (d.value.kind === "interpolated") {
@@ -126,3 +135,104 @@ export function processRuleDeclarations(args: RuleDeclarationContext): void {
126135
}
127136
}
128137
}
138+
139+
// --- Non-exported helpers ---
140+
141+
/**
142+
* Attempts to substitute `__SC_EXPR_N__` placeholders in a CSS property name
143+
* with statically-resolvable string values pulled from the styled component's
144+
* template expressions. Only succeeds when:
145+
* - every placeholder slot resolves to a string literal (directly or via a
146+
* top-level `const NAME = "..."` binding in the same file), and
147+
* - the resulting property name is a CSS custom property (starts with `--`).
148+
*
149+
* Returns the resolved property name on success, or `null` when the property
150+
* cannot be safely lowered.
151+
*/
152+
function resolveInterpolatedPropertyName(
153+
property: string,
154+
ctx: DeclProcessingState,
155+
): string | null {
156+
const { decl, state } = ctx;
157+
const placeholderRe = new RegExp(PLACEHOLDER_RE.source, "g");
158+
let failed = false;
159+
const resolved = property.replace(placeholderRe, (_match, slotIdRaw: string) => {
160+
const slotId = Number(slotIdRaw);
161+
const expr = decl.templateExpressions[slotId];
162+
const value = resolveExpressionToStaticString(expr, state);
163+
if (value === null) {
164+
failed = true;
165+
return "";
166+
}
167+
return value;
168+
});
169+
if (failed) {
170+
return null;
171+
}
172+
// Only substitute names that look like CSS custom properties to avoid
173+
// accidentally turning unrelated dynamic patterns (e.g. computed standard
174+
// property names) into silently mistransformed output.
175+
if (!resolved.startsWith("--")) {
176+
return null;
177+
}
178+
return resolved;
179+
}
180+
181+
/**
182+
* Resolves an AST expression to a static string. Handles direct string literals
183+
* and identifiers bound to top-level `const NAME = "..."` declarations in the
184+
* file being transformed.
185+
*/
186+
function resolveExpressionToStaticString(
187+
expr: unknown,
188+
state: DeclProcessingState["state"],
189+
): string | null {
190+
const direct = literalToStaticValue(expr);
191+
if (typeof direct === "string") {
192+
return direct;
193+
}
194+
if (isIdentifierNode(expr)) {
195+
return findTopLevelConstStringInit(expr.name, state);
196+
}
197+
return null;
198+
}
199+
200+
/**
201+
* Finds a top-level `const <name> = <literal>` declaration in the current file
202+
* and returns its initializer when it resolves to a static string. Skips
203+
* declarators with multiple bindings or non-`const` declarations to avoid
204+
* picking up reassignable values.
205+
*/
206+
function findTopLevelConstStringInit(
207+
name: string,
208+
state: DeclProcessingState["state"],
209+
): string | null {
210+
const { root, j } = state;
211+
let resolved: string | null = null;
212+
root
213+
.find(j.VariableDeclaration, { kind: "const" } as { kind: "const" })
214+
.filter((p) => {
215+
const parentType = (p.parent?.node as { type?: string } | undefined)?.type;
216+
return parentType === "Program" || parentType === "ExportNamedDeclaration";
217+
})
218+
.forEach((p) => {
219+
if (resolved !== null) {
220+
return;
221+
}
222+
for (const declarator of p.node.declarations) {
223+
if (
224+
declarator.type !== "VariableDeclarator" ||
225+
declarator.id.type !== "Identifier" ||
226+
declarator.id.name !== name
227+
) {
228+
continue;
229+
}
230+
const value = literalToStaticValue(declarator.init);
231+
if (typeof value === "string") {
232+
resolved = value;
233+
}
234+
return;
235+
}
236+
});
237+
return resolved;
238+
}

test-cases/_unimplemented.cssVariable-dynamicPropertyName.input.tsx

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// A styled component that sets a CSS variable whose name is provided via a
2+
// template interpolation (e.g. `${ITEM_MIN_WIDTH_VAR}: 100%;`). The codemod
3+
// resolves the identifier to its top-level static string value.
4+
import styled from "styled-components";
5+
6+
const ITEM_MIN_WIDTH_VAR = "--item-min-width";
7+
8+
const Container = styled.div`
9+
${ITEM_MIN_WIDTH_VAR}: 100%;
10+
background-color: orange;
11+
color: white;
12+
padding: 8px;
13+
`;
14+
15+
const Consumer = styled.div`
16+
width: var(--item-min-width);
17+
background-color: teal;
18+
color: white;
19+
padding: 8px;
20+
`;
21+
22+
export const App = () => (
23+
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
24+
<Container>Sets --item-min-width: 100%</Container>
25+
<Consumer>Reads var(--item-min-width)</Consumer>
26+
</div>
27+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as stylex from "@stylexjs/stylex";
2+
3+
const ITEM_MIN_WIDTH_VAR = "--item-min-width";
4+
5+
export const App = () => (
6+
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
7+
<div sx={styles.container}>Sets --item-min-width: 100%</div>
8+
<div sx={styles.consumer}>Reads var(--item-min-width)</div>
9+
</div>
10+
);
11+
12+
const styles = stylex.create({
13+
container: {
14+
"--item-min-width": "100%",
15+
backgroundColor: "orange",
16+
color: "white",
17+
padding: 8,
18+
},
19+
consumer: {
20+
width: "var(--item-min-width)",
21+
backgroundColor: "teal",
22+
color: "white",
23+
padding: 8,
24+
},
25+
});

0 commit comments

Comments
 (0)