feat: update css formatter class to add inherited rules (#2715)

Adds support for inherited CSS rules in CssFormatter, resolving parent
DOM element details and selectors for inherited declarations

Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
Samiya Caur
2026-09-10 13:42:21 +00:00
committed by GitHub
co-authored by Samiya Caur
parent 8c8616fcc4
commit 3d7e7bb438
4 changed files with 306 additions and 12 deletions
+157 -10
View File
@@ -99,7 +99,21 @@ export interface MatchedRule {
properties: StructuredCssProperty[];
}
export type CascadeRule = NodeStyleRule | AnimationRule | MatchedRule;
export interface InheritedRule {
type: 'inherited';
node: {
uid?: string;
selector: string;
};
selector?: string;
matchingSelectors?: string[];
source?: string;
ancestors?: AncestorCSSRule[];
properties: StructuredCssProperty[];
}
export type CascadeRule =
NodeStyleRule | AnimationRule | MatchedRule | InheritedRule;
export interface StructuredCssStyles {
element: {
@@ -347,7 +361,7 @@ interface RuleMetadata {
* Extracts common metadata (`ancestors`, `matchingSelectors`, `source`)
* uniformly across matched rules, inherited rules, and pseudo-element rules.
*/
function getRuleMetadata(
function getCSSStyleRuleMetadata(
rule: DevTools.CSSRule.CSSStyleRule | undefined,
matchedStyles: MatchedStyles,
containerDetails?: Map<ContainerQuery, ResolvedContainerDetails>,
@@ -381,6 +395,46 @@ function formatPropertyLine(prop: StructuredCssProperty): string {
return `${stateStr}${prop.name}: ${prop.value}${imp};`;
}
/**
* Filters properties to only those that can be inherited from an ancestor element.
*/
function getInheritableProperties(
properties: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
): DevTools.CSSProperty.CSSProperty[] {
return properties.filter(prop => {
if (DevTools.CSSMetadata.cssMetadata().isCustomProperty(prop.name)) {
const registered = matchedStyles.getRegisteredProperty?.(prop.name);
if (registered) {
return registered.inherits();
}
}
return DevTools.CSSMetadata.cssMetadata().isPropertyInherited(prop.name);
});
}
/**
* Resolves DOM ancestor node details when a style declaration is inherited.
*/
function getParentNodeInfo(
style: DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
matchedStyles: MatchedStyles,
resolveUid?: UidResolver,
): {uid?: string; selector: string} | undefined {
if (!matchedStyles.isInherited?.(style)) {
return undefined;
}
const parentNode = matchedStyles.nodeForStyle?.(style);
if (!parentNode) {
return undefined;
}
const parentUid = resolveUid?.(parentNode.backendNodeId());
return {
...(parentUid ? {uid: parentUid} : {}),
selector: parentNode.simpleSelector(),
};
}
function getCascadeRuleHeader(rule: CascadeRule): string {
let selector: string;
switch (rule.type) {
@@ -391,6 +445,9 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
case 'matched':
selector = rule.selector;
break;
case 'inherited':
selector = rule.selector ?? 'element.style';
break;
}
const source = 'source' in rule ? rule.source : undefined;
return source ? `${selector} (${source})` : selector;
@@ -481,7 +538,15 @@ function appendCssSectionsToString(
): void {
for (const rule of styles.rules) {
writer.writeEmptyLine();
appendRuleWithAncestors(writer, rule);
if (rule.type === 'inherited') {
const uidStr = rule.node.uid ? ` (uid: "${rule.node.uid}")` : '';
writer.writeLine(`Inherited from ${rule.node.selector}${uidStr}:`);
writer.indent();
appendRuleWithAncestors(writer, rule);
writer.dedent();
} else {
appendRuleWithAncestors(writer, rule);
}
}
}
@@ -515,10 +580,23 @@ export class CssFormatter {
continue;
}
if (matchedStyles.isInherited(style)) {
const inheritedRule = CssFormatter.#createInheritedRule(
style,
properties,
matchedStyles,
options,
);
if (inheritedRule) {
rules.push(inheritedRule);
}
continue;
}
if (style.type === DevTools.CSSStyleDeclaration.Type.Transition) {
rules.push({
type: 'transition',
selector: 'transitions style',
selector: CssFormatter.#getNodeStyleSelector(style),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.type === DevTools.CSSStyleDeclaration.Type.Animation) {
@@ -526,21 +604,19 @@ export class CssFormatter {
rules.push({
type: 'animation',
...(animName ? {name: animName} : {}),
selector: animName ? `${animName} animation` : 'animation style',
selector: CssFormatter.#getNodeStyleSelector(style),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.type === DevTools.CSSStyleDeclaration.Type.Attributes) {
const node = matchedStyles.nodeForStyle(style);
const tag = node ? node.nodeNameInCorrectCase() : '';
rules.push({
type: 'attributes',
selector: tag ? `${tag}[attributes style]` : '[attributes style]',
selector: CssFormatter.#getNodeStyleSelector(style, matchedStyles),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.type === DevTools.CSSStyleDeclaration.Type.Inline) {
rules.push({
type: 'inline',
selector: 'element.style',
selector: CssFormatter.#getNodeStyleSelector(style),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
} else if (style.parentRule instanceof DevTools.CSSRule.CSSStyleRule) {
@@ -562,7 +638,11 @@ export class CssFormatter {
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
): MatchedRule {
const meta = getRuleMetadata(rule, matchedStyles, options.containerDetails);
const meta = getCSSStyleRuleMetadata(
rule,
matchedStyles,
options.containerDetails,
);
return {
type: 'matched',
selector: rule.selectorText(),
@@ -572,6 +652,73 @@ export class CssFormatter {
};
}
static #getNodeStyleSelector(
style: DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
matchedStyles?: MatchedStyles,
): string {
switch (style.type) {
case DevTools.CSSStyleDeclaration.Type.Transition:
return 'transitions style';
case DevTools.CSSStyleDeclaration.Type.Animation: {
const animName = style.animationName();
return animName ? `${animName} animation` : 'animation style';
}
case DevTools.CSSStyleDeclaration.Type.Attributes: {
const node = matchedStyles?.nodeForStyle(style);
const tag = node ? node.nodeNameInCorrectCase() : '';
return tag ? `${tag}[attributes style]` : '[attributes style]';
}
case DevTools.CSSStyleDeclaration.Type.Inline:
return 'element.style';
default:
if (style.parentRule instanceof DevTools.CSSRule.CSSStyleRule) {
return style.parentRule.selectorText();
}
return '';
}
}
static #createInheritedRule(
style: DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
properties: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
options: CssFormatterOptions,
): InheritedRule | undefined {
const node = getParentNodeInfo(style, matchedStyles, options.resolveUid);
if (!node) {
return undefined;
}
const inheritableProps = getInheritableProperties(
properties,
matchedStyles,
);
if (!inheritableProps.length) {
return undefined;
}
const rule =
style.parentRule instanceof DevTools.CSSRule.CSSStyleRule
? style.parentRule
: undefined;
const meta = getCSSStyleRuleMetadata(
rule,
matchedStyles,
options.containerDetails,
);
const selector =
CssFormatter.#getNodeStyleSelector(style, matchedStyles) || undefined;
return {
type: 'inherited',
node,
...(selector ? {selector} : {}),
...meta,
properties: CssFormatter.#formatProperties(
inheritableProps,
matchedStyles,
),
};
}
static #formatProperties(
props: DevTools.CSSProperty.CSSProperty[],
matchedStyles: MatchedStyles,
@@ -242,6 +242,100 @@ Styles for div#main (uid: "1_1"):
(no styles)
`;
exports[`CssFormatter > formats inherited styles from ancestors and ignores non-inheritable ones toJSON 1`] = `
{
"element": {
"uid": "child-1",
"selector": "button"
},
"rules": [
{
"type": "inherited",
"node": {
"selector": "section#parent-sec"
},
"selector": ".parent-style",
"source": "<style>",
"properties": [
{
"name": "color",
"value": "black",
"status": "active"
},
{
"name": "--custom-var",
"value": "10px",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats inherited styles from ancestors and ignores non-inheritable ones toString 1`] = `
Styles for button (uid: "child-1"):
Inherited from section#parent-sec:
.parent-style (<style>) {
color: black;
--custom-var: 10px;
}
`;
exports[`CssFormatter > formats inherited transition and animation styles with parent node toJSON 1`] = `
{
"element": {
"uid": "child-elem",
"selector": "button"
},
"rules": [
{
"type": "inherited",
"node": {
"selector": "div#wrapper"
},
"selector": "transitions style",
"properties": [
{
"name": "color",
"value": "purple",
"status": "active"
}
]
},
{
"type": "inherited",
"node": {
"selector": "div#wrapper"
},
"selector": "pulse animation",
"properties": [
{
"name": "color",
"value": "orange",
"status": "active"
}
]
}
]
}
`;
exports[`CssFormatter > formats inherited transition and animation styles with parent node toString 1`] = `
Styles for button (uid: "child-elem"):
Inherited from div#wrapper:
transitions style {
color: purple;
}
Inherited from div#wrapper:
pulse animation {
color: orange;
}
`;
exports[`CssFormatter > formats injected stylesheet rules toJSON 1`] = `
{
"element": {
+45
View File
@@ -375,4 +375,49 @@ describe('CssFormatter', () => {
const matchedStyles = createMockCSSMatchedStyles({nodeStyles: [style]});
return new CssFormatter(matchedStyles, {uid: 'item-1'});
});
formatterTest(
'formats inherited styles from ancestors and ignores non-inheritable ones',
() => {
const inhStyle = createMockCSSStyleDeclaration(
[
createMockCSSProperty('color', 'black'),
createMockCSSProperty('margin', '20px'),
createMockCSSProperty('--custom-var', '10px'),
],
{rule: createMockCSSStyleRule('.parent-style')},
);
const matchedStyles = createMockCSSMatchedStyles({
inheritedStyles: [inhStyle],
parentNode: 'section#parent-sec',
});
return new CssFormatter(matchedStyles, {uid: 'child-1'});
},
);
formatterTest(
'formats inherited transition and animation styles with parent node',
() => {
const inhTransition = createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'purple')],
{type: DevTools.CSSStyleDeclaration.Type.Transition},
);
const inhAnimation = createMockCSSStyleDeclaration(
[createMockCSSProperty('color', 'orange')],
{
type: DevTools.CSSStyleDeclaration.Type.Animation,
animationName: 'pulse',
},
);
const matchedStyles = createMockCSSMatchedStyles({
inheritedStyles: [inhTransition, inhAnimation],
parentNode: 'div#wrapper',
});
return new CssFormatter(matchedStyles, {uid: 'child-elem'});
},
);
});
+10 -2
View File
@@ -378,6 +378,7 @@ export function createMockCSSStyleRule(
export interface MockCSSMatchedStylesParams {
node?: string | DevTools.DOMModel.DOMNode;
nodeStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
inheritedStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
parentNode?: string | DevTools.DOMModel.DOMNode;
nodeForStyleMap?: Map<
DevTools.CSSStyleDeclaration.CSSStyleDeclaration,
@@ -394,7 +395,11 @@ export function createMockCSSMatchedStyles(
typeof params.node === 'string'
? createMockDOMNode({selector: params.node})
: (params.node ?? createMockDOMNode());
const nodeStyles = params.nodeStyles ?? [];
const inheritedStyles = params.inheritedStyles ?? [];
const nodeStyles = params.nodeStyles
? [...params.nodeStyles, ...inheritedStyles]
: inheritedStyles;
const defaultParentNode =
typeof params.parentNode === 'string'
@@ -409,11 +414,14 @@ export function createMockCSSMatchedStyles(
);
mock.node.returns(mockNode);
mock.nodeStyles.returns(nodeStyles);
mock.inheritedStyles.returns(inheritedStyles);
mock.nodeForStyle.callsFake(
style => nodeForStyleMap.get(style) ?? defaultParentNode ?? null,
);
mock.isInherited.callsFake(style =>
Boolean(inheritedStyles.find(inheritedStyle => inheritedStyle === style)),
);
mock.propertyState.callsFake(prop => propertyStates.get(prop) ?? 'Active');
mock.getMatchingSelectors.callsFake(
rule => params.matchingSelectorsMap?.get(rule) ?? [],