Skip to content

Commit 48e3353

Browse files
committed
Support forward descendant component selectors with dynamic prop interpolations
Implements CSS variable bridge for the `&:hover ${Child} { prop: ${(props) => props.$x} }` pattern. The parent sets a CSS custom property as an inline style, and the child's ancestor-conditional style references it via `var()`. Also reclassifies 3 universal selector test cases from _unimplemented to _unsupported (StyleX has no API for `*`). https://claude.ai/code/session_01SkMXxV5ASTu66vhriVXRRq
1 parent a594606 commit 48e3353

6 files changed

Lines changed: 229 additions & 33 deletions

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

Lines changed: 176 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import {
4848
unwrapArrowFunctionToPropsExpr,
4949
hasThemeAccessInArrowFn,
5050
buildTemplateWithStaticParts,
51+
inlineArrowFunctionBody,
52+
collectPropsFromArrowFn,
5153
} from "./inline-styles.js";
5254
import { extractStaticPartsForDecl } from "./interpolations.js";
5355
import {
@@ -70,6 +72,7 @@ export function processDeclRules(ctx: DeclProcessingState): void {
7072
localVarValues,
7173
cssHelperPropValues,
7274
getComposedDefaultValue,
75+
inlineStyleProps,
7376
} = ctx;
7477
const {
7578
j,
@@ -681,6 +684,23 @@ export function processDeclRules(ctx: DeclProcessingState): void {
681684
{ bailOnUnresolved: true },
682685
);
683686
if (forwardResult === "bail") {
687+
// Try CSS variable bridge: forward prop-based interpolations via CSS custom
688+
// properties set on the parent component's inline style.
689+
if (!crossFileUsage) {
690+
const bridgeResult = tryForwardCssVarBridge(
691+
rule,
692+
bucket,
693+
j,
694+
decl,
695+
overrideStyleKey,
696+
resolveThemeValue,
697+
resolveThemeValueFromFn,
698+
inlineStyleProps,
699+
);
700+
if (bridgeResult !== "bail") {
701+
continue;
702+
}
703+
}
684704
state.markBail();
685705
warnings.push({
686706
severity: "warning",
@@ -1219,6 +1239,102 @@ export function processDeclRules(ctx: DeclProcessingState): void {
12191239

12201240
// --- Non-exported helpers ---
12211241

1242+
/**
1243+
* Attempts to resolve unresolvable interpolations in a forward descendant selector
1244+
* by bridging them via CSS custom properties. The parent component sets the CSS
1245+
* variable as an inline style, and the child's override style references it via `var()`.
1246+
*
1247+
* Only handles single-slot interpolations that are arrow function prop accesses.
1248+
* Returns "bail" if the bridge can't be applied.
1249+
*/
1250+
function tryForwardCssVarBridge(
1251+
rule: { declarations: CssDeclarationIR[] },
1252+
bucket: Record<string, unknown>,
1253+
j: DeclProcessingState["state"]["j"],
1254+
decl: StyledDecl,
1255+
overrideStyleKey: string,
1256+
resolveThemeValue: (expr: unknown) => unknown,
1257+
resolveThemeValueFromFn: (expr: unknown) => unknown,
1258+
parentInlineStyleProps: Array<{ prop: string; expr: ExpressionKind; jsxProp?: string }>,
1259+
): Set<string> | "bail" {
1260+
const writtenProps = new Set<string>();
1261+
1262+
for (const d of rule.declarations) {
1263+
// Static and theme-resolvable declarations use the shared helper
1264+
const sharedResult = writeResolvedDeclaration(
1265+
d,
1266+
bucket,
1267+
j,
1268+
decl,
1269+
resolveThemeValue,
1270+
resolveThemeValueFromFn,
1271+
writtenProps,
1272+
);
1273+
if (sharedResult === "written" || sharedResult === "skip") {
1274+
continue;
1275+
}
1276+
1277+
// sharedResult === "unresolved" — try CSS variable bridge for prop-based expressions
1278+
if (d.value.kind !== "interpolated" || !d.property) {
1279+
return "bail";
1280+
}
1281+
const parts = (d.value as { parts?: Array<{ kind: string; slotId?: number }> }).parts;
1282+
if (!parts) {
1283+
return "bail";
1284+
}
1285+
const slotParts = parts.filter((p) => p.kind === "slot" && p.slotId !== undefined);
1286+
// Only handle single-slot interpolations for now
1287+
if (slotParts.length !== 1 || slotParts[0]!.slotId === undefined) {
1288+
return "bail";
1289+
}
1290+
const slotId = slotParts[0]!.slotId;
1291+
const expr = decl.templateExpressions[slotId];
1292+
if (
1293+
!expr ||
1294+
typeof expr !== "object" ||
1295+
((expr as { type?: string }).type !== "ArrowFunctionExpression" &&
1296+
(expr as { type?: string }).type !== "FunctionExpression")
1297+
) {
1298+
return "bail";
1299+
}
1300+
1301+
// Reject theme accesses — they should be handled by resolveThemeValueFromFn
1302+
if (hasThemeAccessInArrowFn(expr)) {
1303+
return "bail";
1304+
}
1305+
1306+
// Inline the arrow function body to get a wrapper-scope expression
1307+
const inlinedExpr = inlineArrowFunctionBody(j, expr);
1308+
if (!inlinedExpr) {
1309+
return "bail";
1310+
}
1311+
1312+
// Collect and register used props so the wrapper destructures them
1313+
for (const propName of collectPropsFromArrowFn(expr)) {
1314+
ensureShouldForwardPropDrop(decl, propName);
1315+
}
1316+
1317+
// Generate a CSS variable name from the override style key and CSS property
1318+
const varName = `--${overrideStyleKey}-${kebabToCamelCase(d.property)}`;
1319+
1320+
// Set bucket value(s) to var(--name) — shorthand expansion produces the right outputs
1321+
for (const out of cssDeclarationToStylexDeclarations(d)) {
1322+
if (out.value.kind === "static") {
1323+
bucket[out.prop] = cssValueToJs(out.value, d.important, out.prop);
1324+
} else {
1325+
bucket[out.prop] = `var(${varName})`;
1326+
}
1327+
writtenProps.add(out.prop);
1328+
}
1329+
1330+
// Add CSS variable assignment to the parent's inline style props
1331+
parentInlineStyleProps.push({ prop: varName, expr: inlinedExpr });
1332+
decl.needsWrapperComponent = true;
1333+
}
1334+
1335+
return writtenProps;
1336+
}
1337+
12221338
/**
12231339
* Processes rule declarations into a relation override bucket, handling both static
12241340
* and interpolated (theme-resolved) values. Returns "bail" if any interpolated
@@ -1235,40 +1351,70 @@ function processDeclarationsIntoBucket(
12351351
): Set<string> | "bail" {
12361352
const writtenProps = new Set<string>();
12371353
for (const d of rule.declarations) {
1238-
if (d.value.kind === "static") {
1239-
for (const out of cssDeclarationToStylexDeclarations(d)) {
1240-
if (out.value.kind !== "static") {
1241-
continue;
1242-
}
1243-
const v = cssValueToJs(out.value, d.important, out.prop);
1244-
bucket[out.prop] = v;
1245-
writtenProps.add(out.prop);
1246-
}
1247-
} else if (d.value.kind === "interpolated" && d.property) {
1248-
const resolveResult = resolveAllSlots(d, decl, resolveThemeValue, resolveThemeValueFromFn);
1249-
if (resolveResult === "bail") {
1250-
if (options?.bailOnUnresolved) {
1251-
return "bail";
1252-
}
1354+
const result = writeResolvedDeclaration(
1355+
d,
1356+
bucket,
1357+
j,
1358+
decl,
1359+
resolveThemeValue,
1360+
resolveThemeValueFromFn,
1361+
writtenProps,
1362+
);
1363+
if (result === "written" || result === "skip") {
1364+
continue;
1365+
}
1366+
// result === "unresolved"
1367+
if (options?.bailOnUnresolved) {
1368+
return "bail";
1369+
}
1370+
}
1371+
return writtenProps;
1372+
}
1373+
1374+
/**
1375+
* Shared logic for processing a single declaration into a bucket.
1376+
* Handles static values and theme-resolvable interpolations.
1377+
* Returns "written" if handled, "skip" for non-interpolated non-static,
1378+
* or "unresolved" if the interpolation couldn't be resolved.
1379+
*/
1380+
function writeResolvedDeclaration(
1381+
d: CssDeclarationIR,
1382+
bucket: Record<string, unknown>,
1383+
j: DeclProcessingState["state"]["j"],
1384+
decl: { templateExpressions: unknown[] },
1385+
resolveThemeValue: (expr: unknown) => unknown,
1386+
resolveThemeValueFromFn: (expr: unknown) => unknown,
1387+
writtenProps: Set<string>,
1388+
): "written" | "skip" | "unresolved" {
1389+
if (d.value.kind === "static") {
1390+
for (const out of cssDeclarationToStylexDeclarations(d)) {
1391+
if (out.value.kind !== "static") {
12531392
continue;
12541393
}
1255-
if (resolveResult) {
1256-
for (const out of cssDeclarationToStylexDeclarations(d)) {
1257-
if (out.value.kind === "static") {
1258-
// Shorthand expansion produced a static component (e.g., borderWidth from border)
1259-
const v = cssValueToJs(out.value, d.important, out.prop);
1260-
bucket[out.prop] = v;
1261-
} else {
1262-
// Build interpolated value using the output's parts (may differ from original d
1263-
// when shorthand expansion strips the static prefix, e.g., border → borderColor)
1264-
bucket[out.prop] = buildInterpolatedValue(j, { value: out.value }, resolveResult);
1265-
}
1266-
writtenProps.add(out.prop);
1267-
}
1268-
}
1394+
bucket[out.prop] = cssValueToJs(out.value, d.important, out.prop);
1395+
writtenProps.add(out.prop);
12691396
}
1397+
return "written";
12701398
}
1271-
return writtenProps;
1399+
1400+
if (d.value.kind !== "interpolated" || !d.property) {
1401+
return "skip";
1402+
}
1403+
1404+
const resolveResult = resolveAllSlots(d, decl, resolveThemeValue, resolveThemeValueFromFn);
1405+
if (!resolveResult || resolveResult === "bail") {
1406+
return "unresolved";
1407+
}
1408+
1409+
for (const out of cssDeclarationToStylexDeclarations(d)) {
1410+
if (out.value.kind === "static") {
1411+
bucket[out.prop] = cssValueToJs(out.value, d.important, out.prop);
1412+
} else {
1413+
bucket[out.prop] = buildInterpolatedValue(j, { value: out.value }, resolveResult);
1414+
}
1415+
writtenProps.add(out.prop);
1416+
}
1417+
return "written";
12721418
}
12731419

12741420
/**
File renamed without changes.
File renamed without changes.

test-cases/_unimplemented.selector-universalInterpolation.input.tsx renamed to test-cases/_unsupported.selector-universalInterpolation.input.tsx

File renamed without changes.

test-cases/_unimplemented.selector-descendantComponentDynamicProp.input.tsx renamed to test-cases/selector-descendantComponentDynamicProp.input.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
// @expected-warning: Unsupported selector: unresolved interpolation in descendant component selector
1+
// Forward descendant component selector with dynamic prop-based interpolation
22
import styled from "styled-components";
33

44
const Icon = styled.span`
55
width: 16px;
66
height: 16px;
77
`;
88

9-
// Forward descendant selector with unresolvable prop-based interpolation.
10-
// The interpolation can't be resolved to a theme value, so should bail.
9+
// Forward descendant selector with prop-based interpolation.
10+
// The prop value is bridged to the child via a CSS custom property.
1111
const Button = styled.button<{ $color?: string }>`
1212
padding: 8px;
1313
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as React from "react";
2+
import * as stylex from "@stylexjs/stylex";
3+
4+
type ButtonProps = React.PropsWithChildren<{
5+
color?: string;
6+
}>;
7+
8+
// Forward descendant selector with prop-based interpolation.
9+
// The prop value is bridged to the child via a CSS custom property.
10+
function Button(props: ButtonProps) {
11+
const { children, color } = props;
12+
const sx = stylex.props(styles.button, stylex.defaultMarker());
13+
14+
return (
15+
<button
16+
{...sx}
17+
style={
18+
{
19+
...sx.style,
20+
"--iconInButton-color": props.color ?? "red",
21+
} as React.CSSProperties
22+
}
23+
>
24+
{children}
25+
</button>
26+
);
27+
}
28+
29+
export const App = () => (
30+
<Button>
31+
<span sx={[styles.icon, styles.iconInButton]} />
32+
Click
33+
</Button>
34+
);
35+
36+
const styles = stylex.create({
37+
icon: {
38+
width: 16,
39+
height: 16,
40+
},
41+
button: {
42+
padding: 8,
43+
},
44+
iconInButton: {
45+
color: {
46+
default: null,
47+
[stylex.when.ancestor(":hover")]: "var(--iconInButton-color)",
48+
},
49+
},
50+
});

0 commit comments

Comments
 (0)