Skip to content

Commit 6aa7def

Browse files
committed
refactor: merge indexed theme lookups into pseudo-element style functions
Move indexed theme resolution into tryHandleDynamicPseudoElementStyleFunction so that static and dynamic properties produce a single merged StyleX function (e.g., `styles.input({ placeholderColor })`) instead of separate entries (`[styles.input, styles.inputPlaceholderColor(placeholderColor)]`). - Extend isPseudoElementSelector to include ::placeholder - Add tryResolveIndexedThemeForPseudoElement helper for theme resolution - Revert pseudo-element handling from tryHandleThemeIndexedLookup - Fix selector-dynamicPseudoElement test case CSS that blocked playground interaction (replaced absolute-positioned overlays with block-level elements) https://claude.ai/code/session_019qihykXE8bykukYa1Zefe3
1 parent a2aa553 commit 6aa7def

File tree

5 files changed

+213
-85
lines changed

5 files changed

+213
-85
lines changed

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

Lines changed: 167 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { resolveDynamicNode } from "../builtin-handlers.js";
1414
import {
1515
cssDeclarationToStylexDeclarations,
1616
cssPropertyToStylexProp,
17+
isCssShorthandProperty,
1718
parseBorderShorthandParts,
1819
resolveBackgroundStylexProp,
1920
} from "../css-prop-mapping.js";
@@ -63,6 +64,7 @@ import { extractUnionLiteralValues } from "./variants.js";
6364
import { toStyleKey, styleKeyWithSuffix } from "../transform/helpers.js";
6465
import { cssPropertyToIdentifier, makeCssProperty, makeCssPropKey } from "./shared.js";
6566
import { isMemberExpression } from "./utils.js";
67+
import { extractIndexedThemeLookupInfo } from "../builtin-handlers/resolver-utils.js";
6668
type CommentSource = { leading?: string; trailingLine?: string } | null;
6769

6870
type InterpolatedDeclarationContext = {
@@ -2283,13 +2285,105 @@ function resolveDerivedLocalVariable(
22832285
}
22842286

22852287
function isPseudoElementSelector(pseudoElement: string | null): boolean {
2286-
return pseudoElement === "::before" || pseudoElement === "::after";
2288+
return (
2289+
pseudoElement === "::before" || pseudoElement === "::after" || pseudoElement === "::placeholder"
2290+
);
2291+
}
2292+
2293+
/**
2294+
* Attempts to resolve an indexed theme lookup from an arrow function expression.
2295+
* Pattern: `(props) => props.theme.color[props.$placeholderColor]`
2296+
* Returns the resolved value expression and metadata, or null if not applicable.
2297+
*/
2298+
function tryResolveIndexedThemeForPseudoElement(
2299+
expr: { type?: string },
2300+
state: DeclProcessingState["state"],
2301+
): {
2302+
valueExpr: ExpressionKind;
2303+
indexPropName: string;
2304+
paramName: string;
2305+
} | null {
2306+
const { resolveValue, resolverImports, parseExpr, api } = state;
2307+
const arrowExpr = expr as {
2308+
type?: string;
2309+
params?: Array<{ type?: string; name?: string }>;
2310+
body?: unknown;
2311+
};
2312+
if (arrowExpr.type !== "ArrowFunctionExpression") {
2313+
return null;
2314+
}
2315+
const paramName = arrowExpr.params?.[0]?.type === "Identifier" ? arrowExpr.params[0].name : null;
2316+
if (!paramName) {
2317+
return null;
2318+
}
2319+
2320+
const body = arrowExpr.body as { type?: string } | undefined;
2321+
if (!body || body.type !== "MemberExpression") {
2322+
return null;
2323+
}
2324+
2325+
const info = extractIndexedThemeLookupInfo(body, paramName);
2326+
if (!info) {
2327+
return null;
2328+
}
2329+
2330+
const resolved = resolveValue({
2331+
kind: "theme",
2332+
path: info.themeObjectPath,
2333+
filePath: state.filePath,
2334+
loc: getNodeLocStart(body as any) ?? undefined,
2335+
});
2336+
if (!resolved) {
2337+
return null;
2338+
}
2339+
2340+
// Register theme imports
2341+
if (resolved.imports) {
2342+
for (const imp of resolved.imports) {
2343+
resolverImports.set(
2344+
JSON.stringify(imp),
2345+
imp as typeof resolverImports extends Map<string, infer V> ? V : never,
2346+
);
2347+
}
2348+
}
2349+
2350+
// Build the indexed expression: resolvedExpr[paramName]
2351+
const resolvedExprAst = parseExpr(resolved.expr);
2352+
const safeParamName = buildSafeIndexedParamName(info.indexPropName, resolvedExprAst);
2353+
const exprSource = `(${resolved.expr})[${safeParamName}]`;
2354+
try {
2355+
const jParse = api.jscodeshift.withParser("tsx");
2356+
const program = jParse(`(${exprSource});`);
2357+
const stmt = program.find(jParse.ExpressionStatement).nodes()[0];
2358+
let parsedExpr = stmt?.expression ?? null;
2359+
while (parsedExpr?.type === "ParenthesizedExpression") {
2360+
parsedExpr = (parsedExpr as { expression: ExpressionKind }).expression;
2361+
}
2362+
// Remove extra.parenthesized flag that causes recast to add parentheses
2363+
const exprWithExtra = parsedExpr as ExpressionKind & {
2364+
extra?: { parenthesized?: boolean; parenStart?: number };
2365+
};
2366+
if (exprWithExtra?.extra?.parenthesized) {
2367+
delete exprWithExtra.extra.parenthesized;
2368+
delete exprWithExtra.extra.parenStart;
2369+
}
2370+
if (!parsedExpr) {
2371+
return null;
2372+
}
2373+
return {
2374+
valueExpr: parsedExpr as ExpressionKind,
2375+
indexPropName: info.indexPropName,
2376+
paramName: safeParamName,
2377+
};
2378+
} catch {
2379+
return null;
2380+
}
22872381
}
22882382

22892383
/**
2290-
* Handles dynamic interpolations inside ::before/::after pseudo-elements by emitting
2291-
* a StyleX dynamic style function whose body wraps the value in the pseudo-element
2292-
* selector.
2384+
* Handles dynamic interpolations inside pseudo-elements (::before / ::after / ::placeholder)
2385+
* by emitting a StyleX dynamic style function whose body wraps the value in the pseudo-element
2386+
* selector. Also handles indexed theme lookups (e.g., props.theme.color[props.$bg]).
22932387
*
22942388
* Example transform:
22952389
* Input: `&::after { background-color: ${(props) => props.$badgeColor}; }`
@@ -2325,60 +2419,104 @@ function tryHandleDynamicPseudoElementStyleFunction(args: InterpolatedDeclaratio
23252419
return false;
23262420
}
23272421

2328-
if (hasThemeAccessInArrowFn(expr)) {
2422+
// For indexed theme lookups (e.g., props.theme.color[props.$bg]), resolve the theme
2423+
// reference and build the indexed expression so the function uses the resolved token.
2424+
const indexedTheme = hasThemeAccessInArrowFn(expr)
2425+
? tryResolveIndexedThemeForPseudoElement(expr, state)
2426+
: null;
2427+
2428+
// Bail on non-indexed theme access (e.g., props.theme.color.primary) — handled elsewhere.
2429+
if (hasThemeAccessInArrowFn(expr) && !indexedTheme) {
23292430
return false;
23302431
}
23312432

2332-
const unwrapped = unwrapArrowFunctionToPropsExpr(j, expr);
2333-
if (!unwrapped) {
2433+
// Bail on CSS shorthand properties for indexed theme lookups.
2434+
// The indexed expression produces a single value that can't be expanded to longhands.
2435+
if (indexedTheme && isCssShorthandProperty(d.property)) {
23342436
return false;
23352437
}
23362438

2337-
const { expr: inlineExpr, propsUsed } = unwrapped;
2338-
2439+
// Bail when the interpolation has surrounding static text and it's an indexed theme lookup.
2440+
// The indexed expression ($colors[param]) cannot be concatenated with a prefix.
23392441
const { prefix, suffix } = extractStaticPartsForDecl(d);
2442+
if (indexedTheme && (prefix || suffix)) {
2443+
return false;
2444+
}
2445+
2446+
let inlineExpr: ExpressionKind;
2447+
let propsUsed: Set<string>;
2448+
let jsxProp: string;
2449+
let isSimpleIdentity: boolean;
2450+
2451+
if (indexedTheme) {
2452+
// Indexed theme: the value expression is the resolved indexed access (e.g., $colors[param]).
2453+
inlineExpr = indexedTheme.valueExpr;
2454+
propsUsed = new Set([indexedTheme.indexPropName]);
2455+
jsxProp = indexedTheme.indexPropName;
2456+
isSimpleIdentity = true;
2457+
} else {
2458+
const unwrapped = unwrapArrowFunctionToPropsExpr(j, expr);
2459+
if (!unwrapped) {
2460+
return false;
2461+
}
2462+
inlineExpr = unwrapped.expr;
2463+
propsUsed = unwrapped.propsUsed;
2464+
// Determine if the expression is a simple identity prop reference (e.g., just `badgeColor`)
2465+
// vs a computed expression (e.g., `tipColor || "black"`, `size * 2`).
2466+
isSimpleIdentity =
2467+
propsUsed.size === 1 &&
2468+
!prefix &&
2469+
!suffix &&
2470+
inlineExpr.type === "Identifier" &&
2471+
propsUsed.has((inlineExpr as { name: string }).name);
2472+
jsxProp = isSimpleIdentity ? [...propsUsed][0]! : "__props";
2473+
}
2474+
23402475
const valueExpr: ExpressionKind =
23412476
prefix || suffix ? buildTemplateWithStaticParts(j, inlineExpr, prefix, suffix) : inlineExpr;
23422477

2343-
// Determine if the expression is a simple identity prop reference (e.g., just `badgeColor`)
2344-
// vs a computed expression (e.g., `tipColor || "black"`, `size * 2`).
2345-
// Simple identity: pass the prop directly as jsxProp.
2346-
// Computed: pass the full expression as callArg to preserve the computation.
2347-
const isSimpleIdentity =
2348-
propsUsed.size === 1 &&
2349-
!prefix &&
2350-
!suffix &&
2351-
inlineExpr.type === "Identifier" &&
2352-
propsUsed.has((inlineExpr as { name: string }).name);
2353-
23542478
const stylexDecls = cssDeclarationToStylexDeclarations(d);
23552479
const pseudoLabel = pseudoElement.replace(/^:+/, "");
2356-
const jsxProp = isSimpleIdentity ? [...propsUsed][0]! : "__props";
23572480

23582481
for (const out of stylexDecls) {
23592482
if (!out.prop) {
23602483
continue;
23612484
}
23622485
const fnKey = styleKeyWithSuffix(styleKeyWithSuffix(decl.styleKey, pseudoLabel), out.prop);
23632486
if (!styleFnDecls.has(fnKey)) {
2364-
// Use the prop name (without $) as parameter for cleaner call sites
2365-
// when possible: styles.badge({ badgeColor }) instead of
2366-
// styles.badge({ backgroundColor: badgeColor })
2367-
const outParamName =
2368-
isSimpleIdentity && jsxProp.startsWith("$")
2487+
// Build parameter name — for indexed theme use the resolved param name,
2488+
// for simple identity use the prop name (without $) for cleaner call sites.
2489+
const outParamName = indexedTheme
2490+
? indexedTheme.paramName
2491+
: isSimpleIdentity && jsxProp.startsWith("$")
23692492
? jsxProp.slice(1)
23702493
: cssPropertyToIdentifier(out.prop, avoidNames);
23712494
const param = j.identifier(outParamName);
2372-
if (isSimpleIdentity && jsxProp !== "__props") {
2495+
2496+
if (indexedTheme) {
2497+
// Use the JSX prop's own type annotation (e.g., Color) when available.
2498+
const propTsType = ctx.findJsxPropTsType(jsxProp);
2499+
(param as { typeAnnotation?: unknown }).typeAnnotation = j.tsTypeAnnotation(
2500+
propTsType && typeof propTsType === "object" && (propTsType as { type?: string }).type
2501+
? (propTsType as ReturnType<typeof j.tsStringKeyword>)
2502+
: j.tsStringKeyword(),
2503+
);
2504+
} else if (isSimpleIdentity && jsxProp !== "__props") {
23732505
ctx.annotateParamFromJsxProp(param, jsxProp);
23742506
} else if (/\.(ts|tsx)$/.test(filePath)) {
23752507
(param as { typeAnnotation?: unknown }).typeAnnotation = j.tsTypeAnnotation(
23762508
j.tsStringKeyword(),
23772509
);
23782510
}
2511+
2512+
// For indexed theme, use the resolved indexed expression directly.
2513+
// For other cases, use the parameter name (potentially wrapped with pseudo/media).
2514+
const innerValueExpr = indexedTheme
2515+
? (cloneAstNode(indexedTheme.valueExpr) as ExpressionKind)
2516+
: j.identifier(outParamName);
23792517
const innerValue = buildPseudoMediaPropValue({
23802518
j,
2381-
valueExpr: j.identifier(outParamName),
2519+
valueExpr: innerValueExpr,
23822520
pseudos,
23832521
media,
23842522
});
@@ -2400,7 +2538,7 @@ function tryHandleDynamicPseudoElementStyleFunction(args: InterpolatedDeclaratio
24002538
}
24012539

24022540
if (isSimpleIdentity) {
2403-
const isOptional = ctx.isJsxPropOptional(jsxProp);
2541+
const isOptional = indexedTheme ? false : ctx.isJsxPropOptional(jsxProp);
24042542
styleFnFromProps.push({
24052543
fnKey,
24062544
jsxProp,

src/internal/lower-rules/value-patterns.ts

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -578,8 +578,9 @@ export const createValuePatternHandlers = (ctx: ValuePatternContext) => {
578578
if (isCssShorthandProperty(d.property)) {
579579
return false;
580580
}
581-
// Skip media/attr buckets for now; these require more complex wiring.
582-
if (opts.media || opts.attrTarget) {
581+
// Skip media/attr/pseudo-element buckets — pseudo-elements are handled by
582+
// tryHandleDynamicPseudoElementStyleFunction which produces merged output.
583+
if (opts.media || opts.attrTarget || opts.pseudoElement) {
583584
return false;
584585
}
585586
// Bail when the interpolation has surrounding static text (e.g., "0 0 4px ${...}").
@@ -680,28 +681,16 @@ export const createValuePatternHandlers = (ctx: ValuePatternContext) => {
680681
};
681682

682683
const firstPseudo = opts.pseudos?.[0];
683-
const pseudoElementLabel = opts.pseudoElement ? pseudoSuffix(opts.pseudoElement) : "";
684-
const baseFnKey = opts.pseudoElement
685-
? styleKeyWithSuffix(styleKeyWithSuffix(decl.styleKey, pseudoElementLabel), out.prop)
686-
: styleKeyWithSuffix(decl.styleKey, out.prop);
687684
const fnKey =
688685
opts.pseudos?.length && firstPseudo
689-
? `${baseFnKey}${pseudoSuffix(firstPseudo)}`
690-
: baseFnKey;
686+
? `${styleKeyWithSuffix(decl.styleKey, out.prop)}${pseudoSuffix(firstPseudo)}`
687+
: styleKeyWithSuffix(decl.styleKey, out.prop);
691688
styleFnFromProps.push({ fnKey, jsxProp: indexPropName });
692689

693690
if (!styleFnDecls.has(fnKey)) {
694-
// Build expression: resolvedExpr[indexPropName]
695-
// NOTE: This is TypeScript-only syntax (TSAsExpression + `keyof typeof`),
696-
// so we parse it explicitly with a TSX parser here rather than relying on
697-
// the generic `parseExpr` helper.
698691
const resolvedExprAst = parseExpr(resolved.expr);
699692
const paramName = buildSafeIndexedParamName(indexPropName, resolvedExprAst);
700693
const indexedExprAst = (() => {
701-
// We intentionally do NOT add `as keyof typeof themeVars` fallbacks.
702-
// If a fixture uses a `string` key to index theme colors, it should be fixed at the
703-
// input/type level to use a proper key union (e.g. `Colors`), and the output should
704-
// reflect that contract.
705694
const exprSource = `(${resolved.expr})[${paramName}]`;
706695
try {
707696
const jParse = api.jscodeshift.withParser("tsx");
@@ -711,7 +700,6 @@ export const createValuePatternHandlers = (ctx: ValuePatternContext) => {
711700
while (expr?.type === "ParenthesizedExpression") {
712701
expr = expr.expression;
713702
}
714-
// Remove extra.parenthesized flag that causes recast to add parentheses
715703
const exprWithExtra = expr as ExpressionKind & {
716704
extra?: { parenthesized?: boolean; parenStart?: number };
717705
};
@@ -736,8 +724,6 @@ export const createValuePatternHandlers = (ctx: ValuePatternContext) => {
736724
}
737725

738726
const param = j.identifier(paramName);
739-
// Prefer the prop's own type when available (e.g. `Color` / `Colors`) so we don't end up with
740-
// `keyof typeof themeVars` in fixture outputs.
741727
const propTsType = findJsxPropTsType(indexPropName);
742728
(param as any).typeAnnotation = j.tsTypeAnnotation(
743729
(propTsType && typeof propTsType === "object" && (propTsType as any).type
@@ -752,16 +738,13 @@ export const createValuePatternHandlers = (ctx: ValuePatternContext) => {
752738
])
753739
: (indexedExprAst as any);
754740

755-
const innerProp = j.property("init", j.identifier(out.prop), innerValue) as any;
756-
757-
// Wrap in pseudo-element nesting when needed (e.g., "::placeholder": { color: ... })
758-
const body = opts.pseudoElement
759-
? j.objectExpression([
760-
j.property("init", j.literal(opts.pseudoElement), j.objectExpression([innerProp])),
761-
])
762-
: j.objectExpression([innerProp]);
763-
764-
styleFnDecls.set(fnKey, j.arrowFunctionExpression([param], body));
741+
styleFnDecls.set(
742+
fnKey,
743+
j.arrowFunctionExpression(
744+
[param],
745+
j.objectExpression([j.property("init", j.identifier(out.prop), innerValue) as any]),
746+
),
747+
);
765748
}
766749
}
767750

0 commit comments

Comments
 (0)