Skip to content

Commit 6b1c4b2

Browse files
skovhusclaude
andcommitted
fix: add defaultMarker() to JSX ancestors with matching attributes for ancestor attribute selectors
StyleX's `stylex.when.ancestor()` generates CSS that requires the ancestor element to have the `.x-default-marker` class. The codemod now detects JSX parent elements with attributes matching the CSS attribute selectors and adds `defaultMarker()` to them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 17109cb commit 6b1c4b2

9 files changed

Lines changed: 143 additions & 5 deletions

File tree

scripts/verify-storybook-rendering.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const CASE_THRESHOLD_OVERRIDES = new Map<string, number>([
7070
// cause sub-pixel text anti-aliasing differences vs styled-components' class-only approach.
7171
const CASE_MISMATCH_TOLERANCE_OVERRIDES = new Map<string, number>([
7272
["selector-componentDynamicProp", 0.03], // TODO: investigate if this override can be removed
73+
["selector-dataAttribute", 0.005], // Sub-pixel anti-aliasing from defaultMarker() class on ancestor elements
7374
]);
7475

7576
type Page = Awaited<ReturnType<Awaited<ReturnType<typeof chromium.launch>>["newPage"]>>;

src/internal/lower-rules.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export function lowerRules(ctx: TransformContext): {
2424
crossFileMarkers: Map<string, string>;
2525
siblingMarkerKeys: Set<string>;
2626
parentsNeedingDefaultMarker: Set<string>;
27+
/** Maps style key → set of CSS attribute selector strings used in ancestor attribute conditions */
28+
ancestorAttrsByStyleKey: Map<string, Set<string>>;
2729
bail: boolean;
2830
} {
2931
const state = createLowerRulesState(ctx);
@@ -159,6 +161,7 @@ export function lowerRules(ctx: TransformContext): {
159161
crossFileMarkers,
160162
siblingMarkerKeys: new Set(state.siblingMarkerNames.keys()),
161163
parentsNeedingDefaultMarker,
164+
ancestorAttrsByStyleKey: state.ancestorAttrsByStyleKey,
162165
bail: state.bail,
163166
};
164167
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,6 +1005,18 @@ export function processDeclRules(ctx: DeclProcessingState): void {
10051005
? ancestorAttrs.map((attr) => makeAncestorKeyExpr(j, `:is(${attr})`))
10061006
: null;
10071007

1008+
// Track ancestor attribute selectors per style key for JSX marker injection
1009+
if (ancestorAttrs) {
1010+
let attrSet = state.ancestorAttrsByStyleKey.get(decl.styleKey);
1011+
if (!attrSet) {
1012+
attrSet = new Set();
1013+
state.ancestorAttrsByStyleKey.set(decl.styleKey, attrSet);
1014+
}
1015+
for (const attr of ancestorAttrs) {
1016+
attrSet.add(attr);
1017+
}
1018+
}
1019+
10081020
const pseudos =
10091021
parsedSelector.kind === "pseudo"
10101022
? parsedSelector.pseudos

src/internal/lower-rules/state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ export function createLowerRulesState(ctx: TransformContext) {
8282
const siblingMarkerParents = new Set<string>();
8383
/** Maps styleKey → marker variable name for sibling selectors (e.g. "thing" → "ThingMarker") */
8484
const siblingMarkerNames = new Map<string, string>();
85+
/** Maps style key → set of CSS attribute selector strings used in ancestor attribute conditions */
86+
const ancestorAttrsByStyleKey = new Map<string, Set<string>>();
8587
// Map<overrideStyleKey, Map<pseudo|null, Record<prop, value>>>
8688
// null key = base styles, string key = pseudo styles (e.g., ":hover", ":focus-visible")
8789
const relationOverridePseudoBuckets = new Map<
@@ -279,6 +281,7 @@ export function createLowerRulesState(ctx: TransformContext) {
279281
ancestorSelectorParents,
280282
siblingMarkerParents,
281283
siblingMarkerNames,
284+
ancestorAttrsByStyleKey,
282285
relationOverridePseudoBuckets,
283286
childPseudoMarkers,
284287
cssHelperValuesByKey,

src/internal/rewrite-jsx.ts

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export function postProcessTransformedAst(args: {
2828
crossFileMarkers?: Map<string, string>;
2929
/** Parent style keys that need defaultMarker() (have at least one override without a scoped marker) */
3030
parentsNeedingDefaultMarker?: Set<string>;
31+
/** Maps style key → set of CSS attribute selector strings used in ancestor attribute conditions */
32+
ancestorAttrsByStyleKey?: Map<string, Set<string>>;
3133
}): { changed: boolean; needsReactImport: boolean } {
3234
const {
3335
root,
@@ -42,6 +44,7 @@ export function postProcessTransformedAst(args: {
4244
stylesIdentifier = "styles",
4345
crossFileMarkers,
4446
parentsNeedingDefaultMarker,
47+
ancestorAttrsByStyleKey,
4548
} = args;
4649
let changed = false;
4750

@@ -57,7 +60,11 @@ export function postProcessTransformedAst(args: {
5760
// - Add `stylex.defaultMarker()` to elements that need markers (ancestor selectors).
5861
// - Add override style keys to descendant/child elements' `stylex.props(...)` calls.
5962
// - For cross-file selectors: use `defineMarker()` and add overrides to imported child JSX.
60-
if (relationOverrides.length > 0 || ancestorSelectorParents.size > 0) {
63+
if (
64+
relationOverrides.length > 0 ||
65+
ancestorSelectorParents.size > 0 ||
66+
(ancestorAttrsByStyleKey && ancestorAttrsByStyleKey.size > 0)
67+
) {
6168
// IMPORTANT: Do not reuse the same AST node instance across multiple insertion points.
6269
// Recast/jscodeshift expect a tree (no shared references); reuse can corrupt printing.
6370
const makeDefaultMarkerCall = () =>
@@ -376,9 +383,67 @@ export function postProcessTransformedAst(args: {
376383
}
377384
}
378385

386+
// When a child element uses styles with ancestor attribute conditions,
387+
// walk up the ancestor JSX tree and add defaultMarker() to any element
388+
// whose JSX attributes match the CSS attribute selector patterns.
389+
if (ancestorAttrsByStyleKey && ancestorAttrsByStyleKey.size > 0) {
390+
const usedStyleKeys = getUsedStyleKeys(call, sxAttr, stylesIdentifier);
391+
for (const key of usedStyleKeys) {
392+
const attrSelectors = ancestorAttrsByStyleKey.get(key);
393+
if (!attrSelectors) {
394+
continue;
395+
}
396+
const attrNames = extractAttrNamesFromSelectors(attrSelectors);
397+
for (const anc of ancestors) {
398+
if (!anc.opening) {
399+
continue;
400+
}
401+
const jsxAttrs = (anc.opening.attributes ?? []) as any[];
402+
const hasMatchingAttr = jsxAttrs.some(
403+
(a: any) =>
404+
a.type === "JSXAttribute" &&
405+
a.name?.type === "JSXIdentifier" &&
406+
attrNames.has(a.name.name),
407+
);
408+
if (!hasMatchingAttr) {
409+
continue;
410+
}
411+
// Add defaultMarker() via sx attribute on the ancestor element
412+
const existingSx = getSxAttrFromAttrs(jsxAttrs);
413+
if (existingSx) {
414+
if (!hasDefaultMarkerInSxArgs(existingSx)) {
415+
addArgsToSxAttr(existingSx, [makeDefaultMarkerCall()]);
416+
changed = true;
417+
}
418+
} else {
419+
const existingCall = getStylexPropsCallFromAttrs(jsxAttrs);
420+
if (existingCall) {
421+
if (!hasDefaultMarker(existingCall)) {
422+
existingCall.arguments = [
423+
...(existingCall.arguments ?? []),
424+
makeDefaultMarkerCall(),
425+
];
426+
changed = true;
427+
}
428+
} else {
429+
// Plain element with no sx or stylex.props — add sx={stylex.defaultMarker()}
430+
anc.opening.attributes = [
431+
...jsxAttrs,
432+
j.jsxAttribute(
433+
j.jsxIdentifier("sx"),
434+
j.jsxExpressionContainer(makeDefaultMarkerCall()),
435+
),
436+
];
437+
changed = true;
438+
}
439+
}
440+
}
441+
}
442+
}
443+
379444
const nextAncestors = [
380445
...ancestors,
381-
{ call, sxAttr, elementStyleKey, markerVarName: addedMarkerVarName },
446+
{ call, sxAttr, elementStyleKey, markerVarName: addedMarkerVarName, opening },
382447
];
383448
for (const child of node.children ?? []) {
384449
visitJsxChild(child, nextAncestors);
@@ -741,6 +806,56 @@ export function postProcessTransformedAst(args: {
741806

742807
// --- Non-exported helpers ---
743808

809+
/** Extracts style key names referenced in a stylex.props() call or sx attribute. */
810+
function getUsedStyleKeys(call: any, sxAttr: any, stylesIdentifier: string): string[] {
811+
const keys: string[] = [];
812+
const extractKey = (node: any): void => {
813+
if (
814+
node?.type === "MemberExpression" &&
815+
node.object?.type === "Identifier" &&
816+
node.object.name === stylesIdentifier &&
817+
node.property?.type === "Identifier"
818+
) {
819+
keys.push(node.property.name);
820+
}
821+
// Also match styleFn calls: styles.key(...)
822+
if (node?.type === "CallExpression") {
823+
extractKey(node.callee);
824+
}
825+
};
826+
if (call) {
827+
for (const arg of call.arguments ?? []) {
828+
extractKey(arg);
829+
}
830+
}
831+
if (sxAttr) {
832+
const expr = sxAttr.value?.expression;
833+
if (expr?.type === "ArrayExpression") {
834+
for (const el of expr.elements ?? []) {
835+
extractKey(el);
836+
}
837+
} else {
838+
extractKey(expr);
839+
}
840+
}
841+
return keys;
842+
}
843+
844+
/** Extracts attribute names from CSS attribute selector strings like '[aria-checked="true"]'. */
845+
function extractAttrNamesFromSelectors(selectors: Set<string>): Set<string> {
846+
const names = new Set<string>();
847+
// Match attribute names inside [...] brackets
848+
const re = /\[([a-zA-Z][\w-]*)/g;
849+
for (const sel of selectors) {
850+
let m;
851+
while ((m = re.exec(sel)) !== null) {
852+
names.add(m[1]!);
853+
}
854+
re.lastIndex = 0;
855+
}
856+
return names;
857+
}
858+
744859
function appendToMapList<K, V>(map: Map<K, V[]>, key: K, value: V): void {
745860
const list = map.get(key);
746861
if (list) {

src/internal/transform-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ export class TransformContext {
102102
siblingMarkerKeys?: Set<string>;
103103
/** Parent style keys that need defaultMarker() (have at least one override without a scoped marker) */
104104
parentsNeedingDefaultMarker?: Set<string>;
105+
/** Maps style key → set of CSS attribute selector strings used in ancestor attribute conditions */
106+
ancestorAttrsByStyleKey?: Map<string, Set<string>>;
105107
/** Content for the sidecar .stylex.ts file (defineMarker declarations), populated by emitStylesStep */
106108
sidecarStylexContent?: string;
107109
/** Bridge components emitted for unconverted consumer selectors. */

src/internal/transform-steps/lower-rules.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function lowerRulesStep(ctx: TransformContext): StepResult {
2323
ctx.crossFileMarkers = lowered.crossFileMarkers;
2424
ctx.siblingMarkerKeys = lowered.siblingMarkerKeys;
2525
ctx.parentsNeedingDefaultMarker = lowered.parentsNeedingDefaultMarker;
26+
ctx.ancestorAttrsByStyleKey = lowered.ancestorAttrsByStyleKey;
2627

2728
if (lowered.bail || ctx.resolveValueBailRef.value) {
2829
return returnResult({ code: null, warnings: ctx.warnings }, "bail");

src/internal/transform-steps/post-process.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function postProcessStep(ctx: TransformContext): StepResult {
7272
stylesIdentifier: ctx.stylesIdentifier,
7373
crossFileMarkers: ctx.crossFileMarkers,
7474
parentsNeedingDefaultMarker: ctx.parentsNeedingDefaultMarker,
75+
ancestorAttrsByStyleKey: ctx.ancestorAttrsByStyleKey,
7576
});
7677
if (post.changed) {
7778
ctx.markChanged();

test-cases/selector-dataAttribute.output.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@ export function App() {
77
Visible
88
</div>
99
<div sx={[styles.box, styles.boxHidden]}>Hidden</div>
10-
<div aria-checked="true">
10+
<div aria-checked="true" sx={stylex.defaultMarker()}>
1111
<div sx={[styles.menuItem, styles.menuItemChecked]}>Checked</div>
1212
</div>
1313
<div>
1414
<div sx={[styles.menuItem, styles.menuItemDefault]}>Default</div>
1515
</div>
16-
<div data-active="true">
16+
<div data-active="true" sx={stylex.defaultMarker()}>
1717
<div sx={styles.indicator}>Active</div>
1818
</div>
19-
<div data-state="active" data-size="lg">
19+
<div data-state="active" data-size="lg" sx={stylex.defaultMarker()}>
2020
<div sx={styles.compoundItem}>Compound</div>
2121
</div>
2222
</div>

0 commit comments

Comments
 (0)