Skip to content

Commit 1c69ab8

Browse files
skovhusclaude
andauthored
Support no-pseudo reverse and cross-component sibling selectors (#352)
* 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 * fix: address PR review comments for cross-component selectors - Bail on cross-file cross-component sibling selectors instead of silently proceeding - Preserve media query context in cross-component sibling handler - Fix marker assignment on pre-existing relation overrides - Add eslint override for stylex.when.siblingBefore() computed keys - Extend test cases to cover media queries and combined pseudo/no-pseudo patterns https://claude.ai/code/session_01X1Gxfz4reNWtojK37AJpsT * fix: bail on computed media queries inside sibling selectors When resolveMediaAtRulePlaceholders() returns a computed media key (e.g. from an imported breakpoint constant), both the cross-component sibling handler and handleSiblingSelector were silently dropping the media guard by setting media = undefined. This could cause styles to apply unconditionally instead of respecting viewport constraints. Now both handlers bail with a descriptive warning instead of silently dropping the computed media condition. https://claude.ai/code/session_01X1Gxfz4reNWtojK37AJpsT * fix: always emit defaultMarker() alongside scoped markers Scoped markers (from defineMarker) and defaultMarker() must coexist: - Scoped markers enable targeted sibling/no-pseudo matching - defaultMarker() enables regular pseudo-reverse selectors like stylex.when.ancestor(':hover') without an explicit marker arg Previously, components registered in siblingMarkerKeys had their defaultMarker() suppressed, breaking mixed scenarios where the same component was both a sibling target and an ancestor reverse target. Fixes both rewrite-jsx.ts (JSX marker injection) and style-merger.ts (wrapper emission) to always include defaultMarker() for ancestor selector parents. https://claude.ai/code/session_01X1Gxfz4reNWtojK37AJpsT * fix: only emit defaultMarker() when parent has non-scoped overrides Parents that only have scoped marker overrides (sibling selectors, no-pseudo ancestor with defineMarker) don't need defaultMarker() — it would be unnecessary overhead. Only emit defaultMarker() when the parent has at least one override without a markerVarName, which means it uses stylex.when.ancestor(':pseudo') with no marker argument. Introduces parentsNeedingDefaultMarker set computed in lower-rules.ts and plumbed through the pipeline to both JSX emission paths (rewrite-jsx.ts for inlined components, style-merger.ts for wrappers). https://claude.ai/code/session_01X1Gxfz4reNWtojK37AJpsT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a9357f commit 1c69ab8

22 files changed

Lines changed: 491 additions & 52 deletions

eslint.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ export default [
8282
{
8383
// Computed stylex.when.*() keys: rule reports "Keys must be strings"
8484
// for valid computed property keys like [stylex.when.siblingBefore(...)].
85-
files: ["test-cases/selector-siblingMedia.output.tsx"],
85+
files: [
86+
"test-cases/selector-componentSiblingCombinator.output.tsx",
87+
"test-cases/selector-siblingMedia.output.tsx",
88+
],
8689
rules: { "stylex/valid-styles": "off" },
8790
},
8891
{

src/internal/emit-wrappers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export function emitWrappers(args: {
2626
ancestorSelectorParents?: Set<string>;
2727
crossFileMarkers?: Map<string, string>;
2828
siblingMarkerKeys?: Set<string>;
29+
parentsNeedingDefaultMarker?: Set<string>;
2930
useSxProp: boolean;
3031
}): void {
3132
const {
@@ -43,6 +44,7 @@ export function emitWrappers(args: {
4344
ancestorSelectorParents,
4445
crossFileMarkers,
4546
siblingMarkerKeys,
47+
parentsNeedingDefaultMarker,
4648
useSxProp,
4749
} = args;
4850

@@ -66,6 +68,7 @@ export function emitWrappers(args: {
6668
ancestorSelectorParents,
6769
crossFileMarkers,
6870
siblingMarkerKeys,
71+
parentsNeedingDefaultMarker,
6972
useSxProp,
7073
});
7174

src/internal/emit-wrappers/style-merger.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export function emitStyleMerging(args: {
7272
| "emptyStyleKeys"
7373
| "ancestorSelectorParents"
7474
| "crossFileMarkers"
75-
| "siblingMarkerKeys"
75+
| "parentsNeedingDefaultMarker"
7676
| "emitTypes"
7777
| "useSxProp"
7878
>;
@@ -108,7 +108,7 @@ export function emitStyleMerging(args: {
108108
stylesIdentifier,
109109
ancestorSelectorParents,
110110
crossFileMarkers,
111-
siblingMarkerKeys,
111+
parentsNeedingDefaultMarker,
112112
emitTypes,
113113
} = emitter;
114114

@@ -120,8 +120,9 @@ export function emitStyleMerging(args: {
120120
});
121121

122122
// Add a marker when any style arg references an ancestor selector parent.
123-
// Sibling markers (from crossFileMarkers) REPLACE defaultMarker() — they scope sibling
124-
// matching to the component. Cross-file markers coexist with defaultMarker().
123+
// Scoped markers (from defineMarker) and defaultMarker() coexist: the scoped marker
124+
// enables targeted sibling/no-pseudo matching, while defaultMarker() enables regular
125+
// pseudo-reverse selectors like `stylex.when.ancestor(':hover')` (no marker arg).
125126
if (ancestorSelectorParents && ancestorSelectorParents.size > 0) {
126127
let needsDefaultMarker = false;
127128
const pendingMarkers: ExpressionKind[] = [];
@@ -134,9 +135,10 @@ export function emitStyleMerging(args: {
134135
if (markerVarName) {
135136
pendingMarkers.push(j.identifier(markerVarName));
136137
}
137-
// Need defaultMarker() when there's no scoped marker, or when a cross-file
138-
// marker coexists with an ancestor selector (defaultMarker serves the ancestor).
139-
if (!markerVarName || !siblingMarkerKeys.has(key)) {
138+
// Only emit defaultMarker() when this parent has at least one override
139+
// without a scoped marker. Pure sibling/no-pseudo cases only need
140+
// their scoped marker.
141+
if (!markerVarName || parentsNeedingDefaultMarker.has(key)) {
140142
needsDefaultMarker = true;
141143
}
142144
}

src/internal/emit-wrappers/wrapper-emitter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ type WrapperEmitterArgs = {
5050
crossFileMarkers?: Map<string, string>;
5151
/** Style keys that use sibling markers (scoped marker replaces defaultMarker) */
5252
siblingMarkerKeys?: Set<string>;
53+
/** Parent style keys that need defaultMarker() (have at least one override without a scoped marker) */
54+
parentsNeedingDefaultMarker?: Set<string>;
5355
useSxProp: boolean;
5456
};
5557

@@ -68,6 +70,7 @@ export class WrapperEmitter {
6870
readonly ancestorSelectorParents: Set<string>;
6971
readonly crossFileMarkers: Map<string, string>;
7072
readonly siblingMarkerKeys: Set<string>;
73+
readonly parentsNeedingDefaultMarker: Set<string>;
7174
readonly useSxProp: boolean;
7275

7376
// For plain JS/JSX and Flow transforms, skip emitting TS syntax entirely for now.
@@ -95,6 +98,7 @@ export class WrapperEmitter {
9598
this.ancestorSelectorParents = args.ancestorSelectorParents ?? new Set<string>();
9699
this.crossFileMarkers = args.crossFileMarkers ?? new Map<string, string>();
97100
this.siblingMarkerKeys = args.siblingMarkerKeys ?? new Set<string>();
101+
this.parentsNeedingDefaultMarker = args.parentsNeedingDefaultMarker ?? new Set<string>();
98102
this.useSxProp = args.useSxProp;
99103
this.emitTypes = this.filePath.endsWith(".ts") || this.filePath.endsWith(".tsx");
100104
}

src/internal/logger.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,17 @@ 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"
99+
| "Unsupported selector: computed media query inside cross-component sibling selector"
100+
| "Unsupported selector: computed media query inside sibling selector"
98101
| "Unsupported selector: unknown component selector"
99102
| "Unsupported css`` mixin: after-base mixin style is not a plain object"
100103
| "Unsupported css`` mixin: nested contextual conditions in after-base mixin"
101104
| "Unsupported css`` mixin: cannot infer base default for after-base contextual override (base value is non-literal)"
102105
| "css`` helper function interpolation references closure variable that cannot be hoisted"
103106
| "Sibling selector broadened: & + & (adjacent) becomes general sibling (~) in StyleX — interleaved non-matching elements will no longer block the match"
107+
| "Sibling selector broadened: + (adjacent) becomes general sibling (~) in StyleX — interleaved non-matching elements will no longer block the match"
104108
| "Using styled-components components as mixins is not supported; use css`` mixins or strings instead"
105109
| "styled(ImportedComponent) wraps a component whose file contains internal styled-components — convert the base component's file first to avoid CSS cascade conflicts"
106110
| "Transient $-prefixed props renamed on exported component — update consumer call sites to use the new prop names"

src/internal/lower-rules.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function lowerRules(ctx: TransformContext): {
2323
usedCssHelperFunctions: Set<string>;
2424
crossFileMarkers: Map<string, string>;
2525
siblingMarkerKeys: Set<string>;
26+
parentsNeedingDefaultMarker: Set<string>;
2627
bail: boolean;
2728
} {
2829
const state = createLowerRulesState(ctx);
@@ -139,13 +140,25 @@ export function lowerRules(ctx: TransformContext): {
139140
}
140141
}
141142

143+
// Parents that have at least one override WITHOUT a scoped marker need
144+
// defaultMarker() so that `stylex.when.ancestor(':pseudo')` (no marker arg) can match.
145+
// Parents whose overrides ALL use scoped markers (e.g. pure sibling selectors)
146+
// only need their scoped marker — defaultMarker() would be unnecessary overhead.
147+
const parentsNeedingDefaultMarker = new Set<string>();
148+
for (const o of state.relationOverrides) {
149+
if (!o.markerVarName && parentsNeedingMarker.has(o.parentStyleKey)) {
150+
parentsNeedingDefaultMarker.add(o.parentStyleKey);
151+
}
152+
}
153+
142154
return {
143155
resolvedStyleObjects: state.resolvedStyleObjects,
144156
relationOverrides: state.relationOverrides,
145157
ancestorSelectorParents: filteredAncestorParents,
146158
usedCssHelperFunctions: state.usedCssHelperFunctions,
147159
crossFileMarkers,
148160
siblingMarkerKeys: new Set(state.siblingMarkerNames.keys()),
161+
parentsNeedingDefaultMarker,
149162
bail: state.bail,
150163
};
151164
}

0 commit comments

Comments
 (0)