Skip to content

Commit bd3b80c

Browse files
committed
feat: support cross-component sibling and no-pseudo ancestor selectors
Implement two previously unimplemented selector patterns: - `${Link}:focus-visible + &` (cross-component sibling combinator) → stylex.when.siblingBefore(":focus-visible", LinkMarker) - `${Other} &` (component as ancestor without pseudo) → stylex.when.ancestor(":is(*)", OtherMarker) Both use defineMarker() for the referenced component, reusing the existing siblingMarkerNames/siblingMarkerParents → crossFileMarkers pipeline for sidecar generation and JSX marker injection. Move _unimplemented.selector-componentDescendant (uses CSS class selectors) to _unsupported and create new implementable test case. https://claude.ai/code/session_01X1Gxfz4reNWtojK37AJpsT
1 parent 794b5d4 commit bd3b80c

10 files changed

+248
-14
lines changed

src/internal/logger.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,15 @@ export type WarningType =
9494
| "Unsupported selector: unresolved interpolation in descendant component selector"
9595
| "Unsupported selector: unresolved interpolation in element selector"
9696
| "Unsupported selector: unresolved interpolation in reverse component selector"
97+
| "Unsupported selector: unresolved interpolation in cross-component sibling selector"
9798
| "Unsupported selector: grouped reverse selector references different components"
9899
| "Unsupported selector: unknown component selector"
99100
| "Unsupported css`` mixin: after-base mixin style is not a plain object"
100101
| "Unsupported css`` mixin: nested contextual conditions in after-base mixin"
101102
| "Unsupported css`` mixin: cannot infer base default for after-base contextual override (base value is non-literal)"
102103
| "css`` helper function interpolation references closure variable that cannot be hoisted"
103104
| "Sibling selector broadened: & + & (adjacent) becomes general sibling (~) in StyleX — interleaved non-matching elements will no longer block the match"
105+
| "Sibling selector broadened: + (adjacent) becomes general sibling (~) in StyleX — interleaved non-matching elements will no longer block the match"
104106
| "Using styled-components components as mixins is not supported; use css`` mixins or strings instead"
105107
| "styled(ImportedComponent) wraps a component whose file contains internal styled-components — convert the base component's file first to avoid CSS cascade conflicts"
106108
| "Transient $-prefixed props renamed on exported component — update consumer call sites to use the new prop names"

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

Lines changed: 150 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,9 @@ export function processDeclRules(ctx: DeclProcessingState): void {
265265

266266
// Component selector patterns that have special handling below:
267267
// 1. `${Other}:pseudo &` - ancestor pseudo via descendant combinator (space only)
268+
// 1c: `${Other} &` - ancestor without pseudo (no-pseudo reverse)
268269
// 2. `&:pseudo ${Child}` or just `& ${Child}` - parent styling descendant child
270+
// 3. `${Link}:pseudo + &` or `~ &` - cross-component sibling combinator
269271
// Other component selector patterns (like `${Other} .child`) should bail.
270272
const selectorTrimmed = selectorForAnalysis.trim();
271273
const isHandledComponentPattern =
@@ -274,10 +276,14 @@ export function processDeclRules(ctx: DeclProcessingState): void {
274276
// Pattern 1b: comma-separated reverse selectors where each part matches Pattern 1
275277
// e.g., `__SC_EXPR_0__:focus-visible &, __SC_EXPR_1__:active &`
276278
isCommaGroupedReverseSelectorPattern(selectorTrimmed) ||
279+
// Pattern 1c: no-pseudo reverse: `${Other} &` (component as ancestor, no pseudo condition)
280+
/^__SC_EXPR_\d+__\s+&\s*$/.test(selectorTrimmed) ||
277281
// Pattern 2: starts with & (forward descendant/pseudo pattern)
278282
selectorTrimmed.startsWith("&") ||
279283
// Pattern 3: standalone component selector `${Child} { ... }`
280-
/^__SC_EXPR_\d+__\s*\{/.test(selectorTrimmed));
284+
/^__SC_EXPR_\d+__\s*\{/.test(selectorTrimmed) ||
285+
// Pattern 4: cross-component sibling: `${Link}:pseudo + &` or `~ &`
286+
/^__SC_EXPR_\d+__:[a-z][a-z0-9()-]*\s*[+~]\s*&\s*$/.test(selectorTrimmed));
281287

282288
// Use heuristic-based bail checks. We need to allow:
283289
// - Component selectors that have special handling
@@ -389,10 +395,17 @@ export function processDeclRules(ctx: DeclProcessingState): void {
389395
!isReverseSelectorPattern &&
390396
selTrim2.startsWith("__SC_EXPR_") &&
391397
isCommaGroupedReverseSelectorPattern(selTrim2);
398+
// `${Other} &` — no pseudo, component as ancestor. Requires a scoped defineMarker()
399+
// since defaultMarker() would match ANY ancestor, not just Other.
400+
const isNoPseudoReversePattern =
401+
!isReverseSelectorPattern &&
402+
!isGroupedReverseSelectorPattern &&
403+
selTrim2.startsWith("__SC_EXPR_") &&
404+
/^__SC_EXPR_\d+__\s+&\s*$/.test(selTrim2);
392405
if (
393406
otherLocal &&
394407
!isCssHelperPlaceholder &&
395-
(isReverseSelectorPattern || isGroupedReverseSelectorPattern)
408+
(isReverseSelectorPattern || isGroupedReverseSelectorPattern || isNoPseudoReversePattern)
396409
) {
397410
// For grouped selectors, verify ALL slot IDs resolve to the same component.
398411
// Without this guard, `${Link}:focus &, ${Button}:active &` would silently
@@ -432,8 +445,12 @@ export function processDeclRules(ctx: DeclProcessingState): void {
432445
break;
433446
}
434447

435-
// Extract all ancestor pseudos (one per comma-separated part)
436-
const ancestorPseudos = extractReverseSelectorPseudos(rule.selector);
448+
// Extract all ancestor pseudos (one per comma-separated part).
449+
// For no-pseudo reverse (`${Other} &`), use `:is(*)` as synthetic always-matching
450+
// pseudo so the style is conditional on the marker, not unconditional.
451+
const ancestorPseudos = isNoPseudoReversePattern
452+
? [":is(*)"]
453+
: extractReverseSelectorPseudos(rule.selector);
437454
if (ancestorPseudos.length === 0) {
438455
state.markBail();
439456
warnings.push({
@@ -453,8 +470,18 @@ export function processDeclRules(ctx: DeclProcessingState): void {
453470
const overrideStyleKey = `${toStyleKey(decl.localName)}In${jsxParentName}`;
454471
ancestorSelectorParents.add(parentStyleKey);
455472

456-
// For cross-file reverse, register a defineMarker for the imported parent
457-
const reverseMarkerVarName = crossFileParent ? `${jsxParentName}Marker` : undefined;
473+
// Register a defineMarker for the parent:
474+
// - Cross-file reverse always needs a marker
475+
// - No-pseudo reverse needs a scoped marker (defaultMarker() would be too broad)
476+
const needsScopedMarker = isNoPseudoReversePattern || !!crossFileParent;
477+
const reverseMarkerVarName = needsScopedMarker ? `${jsxParentName}Marker` : undefined;
478+
479+
// For no-pseudo reverse with same-file parent, register the marker through
480+
// the sibling marker mechanism (feeds into crossFileMarkers → sidecar generation).
481+
if (isNoPseudoReversePattern && !crossFileParent && reverseMarkerVarName) {
482+
state.siblingMarkerNames.set(parentStyleKey, reverseMarkerVarName);
483+
state.siblingMarkerParents.add(parentStyleKey);
484+
}
458485

459486
const overrideCountBeforeReverse = relationOverrides.length;
460487
// Process declarations once, then register into each pseudo bucket
@@ -476,6 +503,18 @@ export function processDeclRules(ctx: DeclProcessingState): void {
476503
jsxParentName,
477504
);
478505

506+
// For same-file no-pseudo reverse, set markerVarName on the override so
507+
// finalizeRelationOverrides emits stylex.when.ancestor(":is(*)", Marker).
508+
const lastOverride = relationOverrides.at(-1);
509+
if (
510+
isNoPseudoReversePattern &&
511+
!crossFileParent &&
512+
lastOverride &&
513+
relationOverrides.length > overrideCountBeforeReverse
514+
) {
515+
lastOverride.markerVarName = reverseMarkerVarName;
516+
}
517+
479518
const result = processDeclarationsIntoBucket(
480519
rule,
481520
firstBucket,
@@ -616,6 +655,85 @@ export function processDeclRules(ctx: DeclProcessingState): void {
616655
continue;
617656
}
618657

658+
// Cross-component sibling: `${Link}:focus-visible + &` or `${Link}:active ~ &`
659+
// The declaring component reacts when the referenced component is its sibling.
660+
// Uses stylex.when.siblingBefore(":pseudo", ReferencedMarker).
661+
const crossComponentSiblingMatch = otherLocal
662+
? selTrim2.match(/^__SC_EXPR_\d+__:([a-z][a-z0-9()-]*)\s*([+~])\s*&\s*$/)
663+
: null;
664+
if (otherLocal && !isCssHelperPlaceholder && crossComponentSiblingMatch) {
665+
const siblingPseudo = `:${crossComponentSiblingMatch[1]}`;
666+
const combinator = crossComponentSiblingMatch[2] as "+" | "~";
667+
668+
const referencedDecl = declByLocalName.get(otherLocal);
669+
const crossFileRef = !referencedDecl
670+
? state.crossFileSelectorsByLocal.get(otherLocal)
671+
: undefined;
672+
if (!referencedDecl && !crossFileRef) {
673+
state.markBail();
674+
warnings.push({
675+
severity: "warning",
676+
type: "Unsupported selector: unknown component selector",
677+
loc: computeSelectorWarningLoc(decl.loc, decl.rawCss, rule.selector),
678+
});
679+
break;
680+
}
681+
682+
// Emit info warning for `+` since adjacent becomes general sibling
683+
if (combinator === "+") {
684+
warnings.push({
685+
severity: "info",
686+
type: "Sibling selector broadened: + (adjacent) becomes general sibling (~) in StyleX — interleaved non-matching elements will no longer block the match",
687+
loc: computeSelectorWarningLoc(decl.loc, decl.rawCss, rule.selector),
688+
});
689+
}
690+
691+
// Register marker for the referenced component
692+
const jsxRefName = crossFileRef?.bridgeComponentLocalName ?? otherLocal;
693+
const refStyleKey = referencedDecl ? referencedDecl.styleKey : toStyleKey(jsxRefName);
694+
const refMarkerVarName = state.siblingMarkerNames.get(refStyleKey) ?? `${jsxRefName}Marker`;
695+
state.siblingMarkerNames.set(refStyleKey, refMarkerVarName);
696+
state.siblingMarkerParents.add(refStyleKey);
697+
ancestorSelectorParents.add(refStyleKey);
698+
699+
// Process declarations into a temporary bucket
700+
const sibBucket: Record<string, unknown> = {};
701+
const sibResult = processDeclarationsIntoBucket(
702+
rule,
703+
sibBucket,
704+
j,
705+
decl,
706+
resolveThemeValue,
707+
resolveThemeValueFromFn,
708+
{ bailOnUnresolved: true },
709+
);
710+
if (sibResult === "bail") {
711+
state.markBail();
712+
warnings.push({
713+
severity: "warning",
714+
type: "Unsupported selector: unresolved interpolation in cross-component sibling selector",
715+
loc: computeSelectorWarningLoc(decl.loc, decl.rawCss, rule.selector),
716+
});
717+
break;
718+
}
719+
720+
// Build stylex.when.siblingBefore(':pseudo', Marker) per property
721+
const makeSiblingKeyExpr = () =>
722+
j.callExpression(
723+
j.memberExpression(
724+
j.memberExpression(j.identifier("stylex"), j.identifier("when")),
725+
j.identifier("siblingBefore"),
726+
),
727+
[j.literal(siblingPseudo), j.identifier(refMarkerVarName)],
728+
);
729+
730+
for (const [prop, value] of Object.entries(sibBucket)) {
731+
const entry = getOrCreateComputedMediaEntry(prop, ctx);
732+
entry.entries.push({ keyExpr: makeSiblingKeyExpr(), value });
733+
}
734+
continue;
735+
}
736+
619737
// Selector interpolation that's a MemberExpression (e.g., screenSize.phone)
620738
// Try to resolve it via the adapter as a media query helper.
621739
if (!otherLocal && slotExpr && isMemberExpression(slotExpr)) {
@@ -644,7 +762,10 @@ export function processDeclRules(ctx: DeclProcessingState): void {
644762
// Store the resolved media expression for this rule
645763
const mediaExpr = parseExpr(selectorResult.expr);
646764
if (mediaExpr) {
647-
resolvedSelectorMedia = { keyExpr: mediaExpr, exprSource: selectorResult.expr };
765+
resolvedSelectorMedia = {
766+
keyExpr: mediaExpr,
767+
exprSource: selectorResult.expr,
768+
};
648769
// Add required imports
649770
registerImports(selectorResult.imports, resolverImports);
650771
resolved = true;
@@ -936,7 +1057,9 @@ export function processDeclRules(ctx: DeclProcessingState): void {
9361057
(existingVal as Record<string, unknown>)[ps] = value;
9371058
}
9381059
} else {
939-
const pseudoMap: Record<string, unknown> = { default: existingVal ?? null };
1060+
const pseudoMap: Record<string, unknown> = {
1061+
default: existingVal ?? null,
1062+
};
9401063
for (const ps of pseudos) {
9411064
pseudoMap[ps] = value;
9421065
}
@@ -1075,7 +1198,9 @@ function processDeclarationsIntoBucket(
10751198
* slot can't be resolved, or `null` if no slots are found.
10761199
*/
10771200
function resolveAllSlots(
1078-
d: { value: { kind: string; parts?: Array<{ kind: string; slotId?: number }> } },
1201+
d: {
1202+
value: { kind: string; parts?: Array<{ kind: string; slotId?: number }> };
1203+
},
10791204
decl: { templateExpressions: unknown[] },
10801205
resolveThemeValue: (expr: unknown) => unknown,
10811206
resolveThemeValueFromFn: (expr: unknown) => unknown,
@@ -1123,7 +1248,12 @@ function resolveAllSlots(
11231248
*/
11241249
function buildInterpolatedValue(
11251250
j: DeclProcessingState["state"]["j"],
1126-
d: { value: { kind: string; parts?: Array<{ kind: string; value?: string; slotId?: number }> } },
1251+
d: {
1252+
value: {
1253+
kind: string;
1254+
parts?: Array<{ kind: string; value?: string; slotId?: number }>;
1255+
};
1256+
},
11271257
resolveSlot: (slotId: number) => unknown,
11281258
): unknown {
11291259
const parts = d.value.parts ?? [];
@@ -1187,7 +1317,11 @@ function resolveElementSelectorTarget(
11871317
root: DeclProcessingState["state"]["root"],
11881318
j: JSCodeshift,
11891319
):
1190-
| { childDecl: StyledDecl; ancestorPseudo: string | null; childPseudo: string | null }
1320+
| {
1321+
childDecl: StyledDecl;
1322+
ancestorPseudo: string | null;
1323+
childPseudo: string | null;
1324+
}
11911325
| ElementSelectorBailReason
11921326
| null {
11931327
const parsed = parseElementSelectorPattern(selector);
@@ -1651,7 +1785,11 @@ function tagCrossFileOverride(
16511785
function recoverStandaloneInterpolationsInPseudoBlock(
16521786
rule: DeclProcessingState["decl"]["rules"][number],
16531787
decl: DeclProcessingState["decl"],
1654-
): { when: string; propName: string; cssProps: Record<string, unknown> } | null {
1788+
): {
1789+
when: string;
1790+
propName: string;
1791+
cssProps: Record<string, unknown>;
1792+
} | null {
16551793
const { rawCss, templateExpressions } = decl;
16561794
if (!rawCss) {
16571795
return null;

src/internal/lower-rules/relation-overrides.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const finalizeRelationOverrides = (args: {
3636
o.childStyleKey,
3737
...(o.childExtraStyleKeys ?? []),
3838
]);
39-
if (o.crossFile && o.markerVarName) {
39+
if (o.markerVarName) {
4040
overrideToMarker.set(o.overrideStyleKey, o.markerVarName);
4141
}
4242
}

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

File renamed without changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import * as stylex from "@stylexjs/stylex";
2+
3+
export const WrapperMarker = stylex.defineMarker();
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Component used as ancestor selector without pseudo
2+
import styled from "styled-components";
3+
4+
const Wrapper = styled.div`
5+
padding: 16px;
6+
background: papayawhip;
7+
`;
8+
9+
const Child = styled.div`
10+
color: gray;
11+
padding: 8px;
12+
13+
${Wrapper} & {
14+
color: blue;
15+
background: lavender;
16+
}
17+
`;
18+
19+
export const App = () => (
20+
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
21+
<Child>Outside Wrapper (gray)</Child>
22+
<Wrapper>
23+
<Child>Inside Wrapper (blue, lavender)</Child>
24+
</Wrapper>
25+
</div>
26+
);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as stylex from "@stylexjs/stylex";
2+
import { WrapperMarker } from "./selector-componentDescendant.input.stylex";
3+
4+
export const App = () => (
5+
<div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}>
6+
<div sx={styles.child}>Outside Wrapper (gray)</div>
7+
<div sx={[styles.wrapper, WrapperMarker]}>
8+
<div sx={[styles.child, styles.childInWrapper]}>Inside Wrapper (blue, lavender)</div>
9+
</div>
10+
</div>
11+
);
12+
13+
const styles = stylex.create({
14+
wrapper: {
15+
padding: 16,
16+
backgroundColor: "papayawhip",
17+
},
18+
child: {
19+
color: "gray",
20+
padding: 8,
21+
},
22+
childInWrapper: {
23+
color: {
24+
default: "gray",
25+
[stylex.when.ancestor(":is(*)", WrapperMarker)]: "blue",
26+
},
27+
backgroundColor: {
28+
default: null,
29+
[stylex.when.ancestor(":is(*)", WrapperMarker)]: "lavender",
30+
},
31+
},
32+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import * as stylex from "@stylexjs/stylex";
2+
3+
export const LinkMarker = stylex.defineMarker();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// @expected-warning: Unsupported selector: sibling combinator
1+
// Cross-component sibling combinator: ${Link}:focus-visible + &
22
import styled from "styled-components";
33

44
const Link = styled.a`
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as stylex from "@stylexjs/stylex";
2+
import { LinkMarker } from "./selector-componentSiblingCombinator.input.stylex";
3+
4+
export const App = () => (
5+
<div>
6+
<a href="#" sx={[styles.link, LinkMarker]}>
7+
Link
8+
</a>
9+
<span sx={styles.badge}>Badge (blue when Link is focused, adjacent sibling)</span>
10+
</div>
11+
);
12+
13+
const styles = stylex.create({
14+
link: {
15+
display: "flex",
16+
padding: 8,
17+
backgroundColor: "papayawhip",
18+
},
19+
// ${Link}:focus-visible + & uses a sibling combinator between the
20+
// component and self. This is NOT an ancestor relationship, so
21+
// stylex.when.ancestor() would produce incorrect semantics.
22+
badge: {
23+
paddingBlock: 4,
24+
paddingInline: 8,
25+
color: {
26+
default: "gray",
27+
[stylex.when.siblingBefore(":focus-visible", LinkMarker)]: "blue",
28+
},
29+
},
30+
});

0 commit comments

Comments
 (0)