Skip to content

Commit 5e847c4

Browse files
skovhusclaude
andcommitted
feat(selector): support universal selector with inherited CSS properties
Lift `& *` and bare `*` declarations to the parent element when all properties are CSS-inherited (color, font-family, cursor, etc.). Non-inherited properties or complex patterns (& > *, &:hover *, & * *) still bail as before. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5191f71 commit 5e847c4

File tree

6 files changed

+294
-50
lines changed

6 files changed

+294
-50
lines changed

src/internal/collect-styled-decls.ts

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
*/
55
import type { Collection } from "jscodeshift";
66
import {
7+
type CssRuleIR,
78
computeUniversalSelectorLoc,
8-
hasUniversalSelectorInRules,
99
normalizeStylisAstToIR,
10+
rewriteInheritedUniversalRules,
1011
} from "./css-ir.js";
1112
import {
1213
cloneAstNode,
@@ -62,6 +63,19 @@ function collectStyledDeclsImpl(args: {
6263
universalSelectorLoc = computeUniversalSelectorLoc(getNodeLocStart(template), rawCss);
6364
};
6465

66+
/** Rewrite inherited-only `& *` rules to base and note any remaining universals. */
67+
const rewriteUniversalRules = (
68+
rules: CssRuleIR[],
69+
template: any,
70+
rawCss: string,
71+
): CssRuleIR[] => {
72+
const rewritten = rewriteInheritedUniversalRules(rules);
73+
if (rewritten.hasRemainingUniversal) {
74+
noteUniversalSelector(template, rawCss);
75+
}
76+
return rewritten.rules;
77+
};
78+
6579
/**
6680
* Convert a MemberExpression AST node to a string like "animated.div".
6781
* Returns null if the expression doesn't match the expected pattern.
@@ -811,12 +825,11 @@ function collectStyledDeclsImpl(args: {
811825
const template = init.quasi;
812826
const templateLoc = getNodeLocStart(template);
813827
const parsed = parseStyledTemplateLiteral(template);
814-
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
815-
rawCss: parsed.rawCss,
816-
});
817-
if (hasUniversalSelectorInRules(rules)) {
818-
noteUniversalSelector(template, parsed.rawCss);
819-
}
828+
const rules = rewriteUniversalRules(
829+
normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, { rawCss: parsed.rawCss }),
830+
template,
831+
parsed.rawCss,
832+
);
820833

821834
styledDecls.push({
822835
...placementHints,
@@ -845,12 +858,11 @@ function collectStyledDeclsImpl(args: {
845858
const template = init.quasi;
846859
const templateLoc = getNodeLocStart(template);
847860
const parsed = parseStyledTemplateLiteral(template);
848-
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
849-
rawCss: parsed.rawCss,
850-
});
851-
if (hasUniversalSelectorInRules(rules)) {
852-
noteUniversalSelector(template, parsed.rawCss);
853-
}
861+
const rules = rewriteUniversalRules(
862+
normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, { rawCss: parsed.rawCss }),
863+
template,
864+
parsed.rawCss,
865+
);
854866

855867
const attrsInfo = peeled.attrsArg != null ? parseAttrsArg(peeled.attrsArg) : undefined;
856868
const sfpResult =
@@ -897,12 +909,11 @@ function collectStyledDeclsImpl(args: {
897909
const template = init.quasi;
898910
const templateLoc = getNodeLocStart(template);
899911
const parsed = parseStyledTemplateLiteral(template);
900-
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
901-
rawCss: parsed.rawCss,
902-
});
903-
if (hasUniversalSelectorInRules(rules)) {
904-
noteUniversalSelector(template, parsed.rawCss);
905-
}
912+
const rules = rewriteUniversalRules(
913+
normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, { rawCss: parsed.rawCss }),
914+
template,
915+
parsed.rawCss,
916+
);
906917

907918
styledDecls.push({
908919
...placementHints,
@@ -934,12 +945,11 @@ function collectStyledDeclsImpl(args: {
934945
const template = init.quasi;
935946
const templateLoc = getNodeLocStart(template);
936947
const parsed = parseStyledTemplateLiteral(template);
937-
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
938-
rawCss: parsed.rawCss,
939-
});
940-
if (hasUniversalSelectorInRules(rules)) {
941-
noteUniversalSelector(template, parsed.rawCss);
942-
}
948+
const rules = rewriteUniversalRules(
949+
normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, { rawCss: parsed.rawCss }),
950+
template,
951+
parsed.rawCss,
952+
);
943953

944954
styledDecls.push({
945955
...placementHints,
@@ -971,12 +981,11 @@ function collectStyledDeclsImpl(args: {
971981
const template = init.quasi;
972982
const templateLoc = getNodeLocStart(template);
973983
const parsed = parseStyledTemplateLiteral(template);
974-
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
975-
rawCss: parsed.rawCss,
976-
});
977-
if (hasUniversalSelectorInRules(rules)) {
978-
noteUniversalSelector(template, parsed.rawCss);
979-
}
984+
const rules = rewriteUniversalRules(
985+
normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, { rawCss: parsed.rawCss }),
986+
template,
987+
parsed.rawCss,
988+
);
980989

981990
styledDecls.push({
982991
...placementHints,
@@ -1281,12 +1290,11 @@ function collectStyledDeclsImpl(args: {
12811290
// Handle styled.div(props => css`...`) pattern
12821291
const templateLoc = getNodeLocStart(cssTemplate);
12831292
const parsed = parseStyledTemplateLiteral(cssTemplate);
1284-
const rules = normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, {
1285-
rawCss: parsed.rawCss,
1286-
});
1287-
if (hasUniversalSelectorInRules(rules)) {
1288-
noteUniversalSelector(cssTemplate, parsed.rawCss);
1289-
}
1293+
const rules = rewriteUniversalRules(
1294+
normalizeStylisAstToIR(parsed.stylisAst, parsed.slots, { rawCss: parsed.rawCss }),
1295+
cssTemplate,
1296+
parsed.rawCss,
1297+
);
12901298

12911299
// Extract destructured params and transform expressions
12921300
const destructuredParams = extractDestructuredParams(arg0);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* CSS properties that are inherited by default.
3+
* Setting these on a parent element propagates the value to all descendants,
4+
* making `& * { prop: value }` equivalent to setting the property on the parent.
5+
*
6+
* Source of truth: W3C CSS2 full property table (https://www.w3.org/TR/CSS2/propidx.html)
7+
* plus CSS3+ properties verified against MDN formal definitions ("Inherited: yes").
8+
* Excludes deprecated aural CSS properties (azimuth, speak, voice-family, etc.).
9+
*/
10+
11+
const CSS_INHERITED_PROPERTIES: ReadonlySet<string> = new Set([
12+
// Text and font
13+
"color",
14+
"font",
15+
"font-family",
16+
"font-size",
17+
"font-style",
18+
"font-weight",
19+
"font-variant",
20+
"font-stretch",
21+
"font-size-adjust",
22+
"font-optical-sizing",
23+
"font-kerning",
24+
"font-feature-settings",
25+
"font-variation-settings",
26+
"line-height",
27+
"letter-spacing",
28+
"word-spacing",
29+
"text-align",
30+
"text-indent",
31+
"text-transform",
32+
"text-wrap",
33+
"white-space",
34+
"word-break",
35+
"overflow-wrap",
36+
"word-wrap",
37+
"hyphens",
38+
"tab-size",
39+
"text-shadow",
40+
"text-decoration-skip-ink",
41+
"text-underline-offset",
42+
"text-underline-position",
43+
44+
// List
45+
"list-style",
46+
"list-style-type",
47+
"list-style-position",
48+
"list-style-image",
49+
50+
// Table
51+
"border-collapse",
52+
"border-spacing",
53+
"caption-side",
54+
"empty-cells",
55+
56+
// UI and interaction
57+
"cursor",
58+
"visibility",
59+
"pointer-events",
60+
"accent-color",
61+
"caret-color",
62+
"color-scheme",
63+
64+
// Writing and direction
65+
"direction",
66+
"writing-mode",
67+
"text-orientation",
68+
"quotes",
69+
"orphans",
70+
"widows",
71+
]);
72+
73+
/**
74+
* Check if a CSS property is inherited by default.
75+
* Custom properties (--*) are also inherited.
76+
*/
77+
export function isCssInheritedProperty(property: string): boolean {
78+
return property.startsWith("--") || CSS_INHERITED_PROPERTIES.has(property);
79+
}

src/internal/css-ir.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* Core concepts: declaration/value modeling and selector normalization.
44
*/
55
import type { Element } from "stylis";
6+
import { isCssInheritedProperty } from "./css-inherited-properties.js";
67
import { PLACEHOLDER_RE, type StyledInterpolationSlot } from "./styled-css.js";
78
import { isPrettierIgnoreComment } from "./utilities/string-utils.js";
89

@@ -545,10 +546,63 @@ function sameArray(a: readonly string[], b: readonly string[]): boolean {
545546
}
546547

547548
/**
548-
* Check if any rule has a universal selector (`*`) in its selector string.
549+
* Selector pattern for a simple descendant universal selector.
550+
* Stylis normalizes `& * { ... }` to selector `"& *"`, and
551+
* bare `* { ... }` to selector `"*"`. Both are equivalent.
549552
*/
550-
export function hasUniversalSelectorInRules(rules: CssRuleIR[]): boolean {
551-
return rules.some((r) => typeof r.selector === "string" && r.selector.includes("*"));
553+
const SIMPLE_DESCENDANT_UNIVERSAL_RE = /^(&\s+\*|\*)$/;
554+
555+
/**
556+
* Rewrite `& *` rules whose declarations are ALL inherited CSS properties
557+
* into base `&` rules (lifting them to the parent element).
558+
*
559+
* Returns the (possibly modified) rules and whether any un-rewritable
560+
* universal selectors remain (which should still trigger a bail).
561+
*/
562+
export function rewriteInheritedUniversalRules(rules: CssRuleIR[]): {
563+
rules: CssRuleIR[];
564+
hasRemainingUniversal: boolean;
565+
} {
566+
// Quick check: if no rule contains `*` at all, skip detailed analysis
567+
if (!rules.some((r) => typeof r.selector === "string" && r.selector.includes("*"))) {
568+
return { rules, hasRemainingUniversal: false };
569+
}
570+
571+
let hasRemainingUniversal = false;
572+
const result: CssRuleIR[] = [];
573+
574+
for (const rule of rules) {
575+
if (typeof rule.selector !== "string" || !rule.selector.includes("*")) {
576+
result.push(rule);
577+
continue;
578+
}
579+
580+
// Only handle simple `& *` (descendant universal) with no at-rules
581+
if (!SIMPLE_DESCENDANT_UNIVERSAL_RE.test(rule.selector) || rule.atRuleStack.length > 0) {
582+
hasRemainingUniversal = true;
583+
result.push(rule);
584+
continue;
585+
}
586+
587+
// Check if ALL declarations use inherited properties
588+
const allInherited = rule.declarations.every((d) => isCssInheritedProperty(d.property));
589+
590+
if (!allInherited) {
591+
hasRemainingUniversal = true;
592+
result.push(rule);
593+
continue;
594+
}
595+
596+
// Rewrite: merge declarations into the base `&` rule
597+
const baseRule = result.find((r) => r.selector === "&" && r.atRuleStack.length === 0);
598+
if (baseRule) {
599+
baseRule.declarations.push(...rule.declarations);
600+
} else {
601+
result.push({ selector: "&", atRuleStack: [], declarations: [...rule.declarations] });
602+
}
603+
}
604+
605+
return { rules: result, hasRemainingUniversal };
552606
}
553607

554608
/**

src/internal/transform/css-helpers.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import type { CssRuleIR } from "../css-ir.js";
99
import { getNodeLocStart } from "../utilities/jscodeshift-utils.js";
1010
import {
1111
computeUniversalSelectorLoc,
12-
hasUniversalSelectorInRules,
1312
normalizeStylisAstToIR,
13+
rewriteInheritedUniversalRules,
1414
} from "../css-ir.js";
1515
import { parseStyledTemplateLiteral } from "../styled-css.js";
1616
import type { StyledDecl } from "../transform-types.js";
@@ -130,6 +130,15 @@ export function isIdentifierReference(p: any): boolean {
130130
return true;
131131
}
132132

133+
/** Rewrite inherited-only `& *` rules to base and notify via callback if universals remain. */
134+
function applyUniversalRewrite(rules: CssRuleIR[], onRemaining: () => void): CssRuleIR[] {
135+
const rewritten = rewriteInheritedUniversalRules(rules);
136+
if (rewritten.hasRemainingUniversal) {
137+
onRemaining();
138+
}
139+
return rewritten.rules;
140+
}
141+
133142
function buildExportedLocalNames(root: any, j: JSCodeshift): Set<string> {
134143
const exportedLocalNames = new Set<string>();
135144
root.find(j.ExportNamedDeclaration).forEach((p: any) => {
@@ -246,10 +255,10 @@ function parseCssHelperTemplate(args: {
246255
const parsed = parseStyledTemplateLiteral(template);
247256
const rawCss = `& { ${parsed.rawCss} }`;
248257
const stylisAst = compile(rawCss);
249-
const rules = normalizeStylisAstToIR(stylisAst, parsed.slots, { rawCss });
250-
if (hasUniversalSelectorInRules(rules)) {
251-
noteUniversalSelector(template, parsed.rawCss);
252-
}
258+
const rules = applyUniversalRewrite(
259+
normalizeStylisAstToIR(stylisAst, parsed.slots, { rawCss }),
260+
() => noteUniversalSelector(template, parsed.rawCss),
261+
);
253262
return {
254263
rules,
255264
rawCss,
@@ -1144,11 +1153,10 @@ export function extractAndRemoveCssHelpers(args: {
11441153

11451154
const rawCss = `& { ${parsed.rawCss} }`;
11461155
const stylisAst = compile(rawCss);
1147-
const rules = normalizeStylisAstToIR(stylisAst, parsed.slots, { rawCss });
1148-
1149-
if (hasUniversalSelectorInRules(rules)) {
1150-
noteCssHelperUniversalSelector(template, parsed.rawCss);
1151-
}
1156+
const rules = applyUniversalRewrite(
1157+
normalizeStylisAstToIR(stylisAst, parsed.slots, { rawCss }),
1158+
() => noteCssHelperUniversalSelector(template, parsed.rawCss),
1159+
);
11521160

11531161
// Create a qualified name for the style key: objectName + PropName
11541162
const qualifiedName = `${objectName}.${propName}`;

0 commit comments

Comments
 (0)