Skip to content

Commit b478e91

Browse files
mattrbeckcrisbeto
authored andcommitted
fix(compiler): support arbitrary nesting in :host-context()
Previously we supported one level of nested pseudo-element selectors inside :host-context(), e.g. :host-context(:is(.foo, .bar)). This was based on a regex-based approach. We could support deeper levels of nesting by updating the regex, but using a regex approach prohibits us from supporting arbitrary nesting. Rather than just adding one more level to the existing expression, I've added a new generator function which splits selectors on commas in a parenthesis-aware way. This allows us to support arbitrary nesting. It's likely we'll want to reuse this in other places where we're not as careful today. We'll probably do this on a request-based basis, though. Fixes angular#59176
1 parent 7a187a3 commit b478e91

File tree

2 files changed

+80
-42
lines changed

2 files changed

+80
-42
lines changed

packages/compiler/src/shadow_css.ts

Lines changed: 63 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8+
import * as chars from './chars';
89

910
/**
1011
* The following set contains all keywords that can be used in the animation css shorthand
@@ -525,6 +526,40 @@ export class ShadowCss {
525526
});
526527
}
527528

529+
/**
530+
* Generator function that splits a string on top-level commas (commas that are not inside parentheses).
531+
* Yields each part of the string between top-level commas. Terminates if an extra closing paren is found.
532+
*
533+
* @param text The string to split
534+
*/
535+
private *_splitOnTopLevelCommas(text: string): Generator<string> {
536+
const length = text.length;
537+
let parens = 0;
538+
let prev = 0;
539+
540+
for (let i = 0; i < length; i++) {
541+
const charCode = text.charCodeAt(i);
542+
543+
if (charCode === chars.$LPAREN) {
544+
parens++;
545+
} else if (charCode === chars.$RPAREN) {
546+
parens--;
547+
if (parens < 0) {
548+
// Found an extra closing paren. Assume we want the list terminated here
549+
yield text.slice(prev, i);
550+
return;
551+
}
552+
} else if (charCode === chars.$COMMA && parens === 0) {
553+
// Found a top-level comma, yield the current chunk
554+
yield text.slice(prev, i);
555+
prev = i + 1;
556+
}
557+
}
558+
559+
// Yield the final chunk
560+
yield text.slice(prev);
561+
}
562+
528563
/*
529564
* convert a rule like :host-context(.foo) > .bar { }
530565
*
@@ -541,38 +576,14 @@ export class ShadowCss {
541576
* .foo<scopeName> .bar { ... }
542577
*/
543578
private _convertColonHostContext(cssText: string): string {
544-
const length = cssText.length;
545-
let parens = 0;
546-
let prev = 0;
547-
let result = '';
548-
549579
// Splits up the selectors on their top-level commas, processes the :host-context in them
550580
// individually and stitches them back together. This ensures that individual selectors don't
551581
// affect each other.
552-
for (let i = 0; i < length; i++) {
553-
const char = cssText[i];
554-
555-
// If we hit a comma and there are no open parentheses, take the current chunk and process it.
556-
if (char === ',' && parens === 0) {
557-
result += this._convertColonHostContextInSelectorPart(cssText.slice(prev, i)) + ',';
558-
prev = i + 1;
559-
continue;
560-
}
561-
562-
// We've hit the end. Take everything since the last comma.
563-
if (i === length - 1) {
564-
result += this._convertColonHostContextInSelectorPart(cssText.slice(prev));
565-
break;
566-
}
567-
568-
if (char === '(') {
569-
parens++;
570-
} else if (char === ')') {
571-
parens--;
572-
}
582+
const results: string[] = [];
583+
for (const part of this._splitOnTopLevelCommas(cssText)) {
584+
results.push(this._convertColonHostContextInSelectorPart(part));
573585
}
574-
575-
return result;
586+
return results.join(',');
576587
}
577588

578589
private _convertColonHostContextInSelectorPart(cssText: string): string {
@@ -587,18 +598,28 @@ export class ShadowCss {
587598

588599
// There may be more than `:host-context` in this selector so `selectorText` could look like:
589600
// `:host-context(.one):host-context(.two)`.
590-
// Execute `_cssColonHostContextRe` over and over until we have extracted all the
591-
// `:host-context` selectors from this selector.
592-
let match: RegExpExecArray | null;
593-
while ((match = _cssColonHostContextRe.exec(selectorText))) {
594-
// `match` = [':host-context(<selectors>)<rest>', <selectors>, <rest>]
595-
596-
// The `<selectors>` could actually be a comma separated list: `:host-context(.one, .two)`.
597-
const newContextSelectors = (match[1] ?? '')
598-
.trim()
599-
.split(',')
600-
.map((m) => m.trim())
601-
.filter((m) => m !== '');
601+
// Loop until every :host-context in the compound selector has been processed.
602+
let startIndex = selectorText.indexOf(_polyfillHostContext);
603+
while (startIndex !== -1) {
604+
const afterPrefix = selectorText.substring(startIndex + _polyfillHostContext.length);
605+
606+
if (!afterPrefix || afterPrefix[0] !== '(') {
607+
// Edge case of :host-context with no parens (e.g. `:host-context .inner`)
608+
selectorText = afterPrefix;
609+
startIndex = selectorText.indexOf(_polyfillHostContext);
610+
continue;
611+
}
612+
613+
// Extract comma-separated selectors between the parentheses
614+
const newContextSelectors: string[] = [];
615+
let endIndex = 0; // Index of the closing paren of the :host-context()
616+
for (const selector of this._splitOnTopLevelCommas(afterPrefix.substring(1))) {
617+
endIndex = endIndex + selector.length + 1;
618+
const trimmed = selector.trim();
619+
if (trimmed) {
620+
newContextSelectors.push(trimmed);
621+
}
622+
}
602623

603624
// We must duplicate the current selector group for each of these new selectors.
604625
// For example if the current groups are:
@@ -627,7 +648,8 @@ export class ShadowCss {
627648
}
628649

629650
// Update the `selectorText` and see repeat to see if there are more `:host-context`s.
630-
selectorText = match[2];
651+
selectorText = afterPrefix.substring(endIndex + 1);
652+
startIndex = selectorText.indexOf(_polyfillHostContext);
631653
}
632654

633655
// The context selectors now must be combined with each other to capture all the possible
@@ -1054,7 +1076,6 @@ const _cssColonHostContextReGlobal = new RegExp(
10541076
`${_cssScopedPseudoFunctionPrefix}(${_hostContextPattern})`,
10551077
'gim',
10561078
);
1057-
const _cssColonHostContextRe = new RegExp(_hostContextPattern, 'im');
10581079
const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator';
10591080
const _polyfillHostNoCombinatorOutsidePseudoFunction = new RegExp(
10601081
`${_polyfillHostNoCombinator}(?![^(]*\\))`,

packages/compiler/test/shadow_css/host_and_host_context_spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,20 @@ describe('ShadowCss, :host and :host-context', () => {
169169
);
170170
});
171171

172+
it('should transform :host-context with nested pseudo selectors', () => {
173+
expect(shim(':host-context(:where(.foo:not(.bar))) {}', 'contenta', 'hosta')).toEqualCss(
174+
':where(.foo:not(.bar))[hosta], :where(.foo:not(.bar)) [hosta] {}',
175+
);
176+
expect(shim(':host-context(:is(.foo:not(.bar))) {}', 'contenta', 'hosta')).toEqualCss(
177+
':is(.foo:not(.bar))[hosta], :is(.foo:not(.bar)) [hosta] {}',
178+
);
179+
expect(
180+
shim(':host-context(:where(.foo:not(.bar, .baz))) .inner {}', 'contenta', 'hosta'),
181+
).toEqualCss(
182+
':where(.foo:not(.bar, .baz))[hosta] .inner[contenta], :where(.foo:not(.bar, .baz)) [hosta] .inner[contenta] {}',
183+
);
184+
});
185+
172186
it('should handle tag selector', () => {
173187
expect(shim(':host-context(div) {}', 'contenta', 'a-host')).toEqualCss(
174188
'div[a-host], div [a-host] {}',
@@ -243,6 +257,9 @@ describe('ShadowCss, :host and :host-context', () => {
243257
expect(shim(':host-context() .inner {}', 'contenta', 'a-host')).toEqualCss(
244258
'[a-host] .inner[contenta] {}',
245259
);
260+
expect(shim(':host-context :host-context(.a) {}', 'contenta', 'host-a')).toEqualCss(
261+
'.a[host-a], .a [host-a] {}',
262+
);
246263
});
247264

248265
// More than one selector such as this is not valid as part of the :host-context spec.

0 commit comments

Comments
 (0)