fix(compiler): handle :host-context with comma-separated child selector (#59276)

Both `:host` and `:host-context` work by looking for a specific character sequence that is terminated by `,` or `{` and replacing selectors inside of it with scoped versions. This is implemented as a regex which isn't aware of things like nested selectors. Normally this is fine for `:host`, because each `:host` produces one scoped selector which doesn't affect any child selectors, however it breaks down with `:host-context` which replaces each instance with two selectors. For example, if we have a selector in the form of `:host-context(.foo) a:not(.a, .b)`, the compiler ends up determining that `.a,` is the end selector and produces `.foo[a-host] a[contenta]:not(.a, .foo [a-host] a[contenta]:not(.a, .b) {}`.

These changes resolve the issue by splitting the CSS alogn top-level commas, processing the `:host-context` in them individually, and stiching the CSS back together.

PR Close #59276
This commit is contained in:
Kristiyan Kostadinov
2024-12-21 16:09:39 +02:00
committed by Andrew Kushnir
parent 64836215c6
commit ecfb74d287
2 changed files with 59 additions and 4 deletions
+42 -4
View File
@@ -541,6 +541,41 @@ export class ShadowCss {
* .foo<scopeName> .bar { ... }
*/
private _convertColonHostContext(cssText: string): string {
const length = cssText.length;
let parens = 0;
let prev = 0;
let result = '';
// Splits up the selectors on their top-level commas, processes the :host-context in them
// individually and stitches them back together. This ensures that individual selectors don't
// affect each other.
for (let i = 0; i < length; i++) {
const char = cssText[i];
// If we hit a comma and there are no open parentheses, take the current chunk and process it.
if (char === ',' && parens === 0) {
result += this._convertColonHostContextInSelectorPart(cssText.slice(prev, i)) + ',';
prev = i + 1;
continue;
}
// We've hit the end. Take everything since the last comma.
if (i === length - 1) {
result += this._convertColonHostContextInSelectorPart(cssText.slice(prev));
break;
}
if (char === '(') {
parens++;
} else if (char === ')') {
parens--;
}
}
return result;
}
private _convertColonHostContextInSelectorPart(cssText: string): string {
return cssText.replace(_cssColonHostContextReGlobal, (selectorText, pseudoPrefix) => {
// We have captured a selector that contains a `:host-context` rule.
@@ -1010,13 +1045,16 @@ const _cssContentUnscopedRuleRe =
const _polyfillHost = '-shadowcsshost';
// note: :host-context pre-processed to -shadowcsshostcontext.
const _polyfillHostContext = '-shadowcsscontext';
const _parenSuffix = '(?:\\((' + '(?:\\([^)(]*\\)|[^)(]*)+?' + ')\\))?([^,{]*)';
const _cssColonHostRe = new RegExp(_polyfillHost + _parenSuffix, 'gim');
const _parenSuffix = '(?:\\((' + '(?:\\([^)(]*\\)|[^)(]*)+?' + ')\\))';
const _cssColonHostRe = new RegExp(_polyfillHost + _parenSuffix + '?([^,{]*)', 'gim');
// note: :host-context patterns are terminated with `{`, as opposed to :host which
// is both `{` and `,` because :host-context handles top-level commas differently.
const _hostContextPattern = _polyfillHostContext + _parenSuffix + '?([^{]*)';
const _cssColonHostContextReGlobal = new RegExp(
_cssScopedPseudoFunctionPrefix + '(' + _polyfillHostContext + _parenSuffix + ')',
`${_cssScopedPseudoFunctionPrefix}(${_hostContextPattern})`,
'gim',
);
const _cssColonHostContextRe = new RegExp(_polyfillHostContext + _parenSuffix, 'im');
const _cssColonHostContextRe = new RegExp(_hostContextPattern, 'im');
const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator';
const _polyfillHostNoCombinatorOutsidePseudoFunction = new RegExp(
`${_polyfillHostNoCombinator}(?![^(]*\\))`,
@@ -257,6 +257,23 @@ describe('ShadowCss, :host and :host-context', () => {
'{}',
);
});
it('should handle :host-context with comma-separated child selector', () => {
expect(shim(':host-context(.foo) a:not(.a, .b) {}', 'contenta', 'a-host')).toEqualCss(
'.foo[a-host] a[contenta]:not(.a, .b), .foo [a-host] a[contenta]:not(.a, .b) {}',
);
expect(
shim(
':host-context(.foo) a:not([a], .b), .bar, :host-context(.baz) a:not([c], .d) {}',
'contenta',
'a-host',
),
).toEqualCss(
'.foo[a-host] a[contenta]:not([a], .b), .foo [a-host] a[contenta]:not([a], .b), ' +
'.bar[contenta], .baz[a-host] a[contenta]:not([c], .d), ' +
'.baz [a-host] a[contenta]:not([c], .d) {}',
);
});
});
describe(':host-context and :host combination selector', () => {