From 07b7af332f164ab150f4b7150e580591c92ca9d5 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Wed, 20 Jan 2021 15:21:54 +0000 Subject: [PATCH] fix(compiler): support multiple `:host-context()` selectors (#40494) In `ViewEncapsulation.Emulated` mode, the compiler must generate additional combinations of selectors to handle the `:host-context()` pseudo-class function. Previously, when there is was more than one `:host-context()` selector in a rule, the compiler was generating invalid selectors. This commit generates all possible combinations of selectors needed to match the same elements as the native `:host-context()` selector. Fixes #19199 PR Close #40494 --- packages/compiler/src/shadow_css.ts | 119 +++++++++++++++------- packages/compiler/test/shadow_css_spec.ts | 40 ++++++++ 2 files changed, 125 insertions(+), 34 deletions(-) diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 5fe6ecb8a8..4965756e91 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -260,7 +260,21 @@ export class ShadowCss { * .foo > .bar */ private _convertColonHost(cssText: string): string { - return this._convertColonRule(cssText, _cssColonHostRe, this._colonHostPartReplacer); + return cssText.replace(_cssColonHostRe, (_, hostSelectors: string, otherSelectors: string) => { + if (hostSelectors) { + const convertedSelectors: string[] = []; + const hostSelectorArray = hostSelectors.split(',').map(p => p.trim()); + for (const hostSelector of hostSelectorArray) { + if (!hostSelector) break; + const convertedSelector = + _polyfillHostNoCombinator + hostSelector.replace(_polyfillHost, '') + otherSelectors; + convertedSelectors.push(convertedSelector); + } + return convertedSelectors.join(','); + } else { + return _polyfillHostNoCombinator + otherSelectors; + } + }); } /* @@ -268,7 +282,7 @@ export class ShadowCss { * * to * - * .foo > .bar, .foo scopeName > .bar { } + * .foo > .bar, .foo > .bar { } * * and * @@ -279,40 +293,28 @@ export class ShadowCss { * .foo .bar { ... } */ private _convertColonHostContext(cssText: string): string { - return this._convertColonRule( - cssText, _cssColonHostContextRe, this._colonHostContextPartReplacer); - } + return cssText.replace(_cssColonHostContextReGlobal, selectorText => { + // We have captured a selector that contains a `:host-context` rule. + // There may be more than one so `selectorText` could look like: + // `:host-context(.one):host-context(.two)`. - private _convertColonRule(cssText: string, regExp: RegExp, partReplacer: Function): string { - // m[1] = :host(-context), m[2] = contents of (), m[3] rest of rule - return cssText.replace(regExp, function(...m: string[]) { - if (m[2]) { - const parts = m[2].split(','); - const r: string[] = []; - for (let i = 0; i < parts.length; i++) { - const p = parts[i].trim(); - if (!p) break; - r.push(partReplacer(_polyfillHostNoCombinator, p, m[3])); - } - return r.join(','); - } else { - return _polyfillHostNoCombinator + m[3]; + const contextSelectors: string[] = []; + let match: RegExpMatchArray|null; + + // Execute `_cssColonHostContextRe` over and over until we have extracted all the + // `:host-context` selectors from this selector. + while (match = _cssColonHostContextRe.exec(selectorText)) { + // `match` = [':host-context()', , ] + contextSelectors.push(match[1].trim()); + selectorText = match[2]; } + + // The context selectors now must be combined with each other to capture all the possible + // selectors that `:host-context` can match. + return combineHostContextSelectors(_polyfillHostNoCombinator, contextSelectors, selectorText); }); } - private _colonHostContextPartReplacer(host: string, part: string, suffix: string): string { - if (part.indexOf(_polyfillHost) > -1) { - return this._colonHostPartReplacer(host, part, suffix); - } else { - return host + part + suffix + ', ' + part + ' ' + host + suffix; - } - } - - private _colonHostPartReplacer(host: string, part: string, suffix: string): string { - return host + part.replace(_polyfillHost, '') + suffix; - } - /* * Convert combinators like ::shadow and pseudo-elements like ::content * by replacing with space. @@ -534,11 +536,12 @@ const _cssContentUnscopedRuleRe = const _polyfillHost = '-shadowcsshost'; // note: :host-context pre-processed to -shadowcsshostcontext. const _polyfillHostContext = '-shadowcsscontext'; -const _parenSuffix = ')(?:\\((' + +const _parenSuffix = '(?:\\((' + '(?:\\([^)(]*\\)|[^)(]*)+?' + ')\\))?([^,{]*)'; -const _cssColonHostRe = new RegExp('(' + _polyfillHost + _parenSuffix, 'gim'); -const _cssColonHostContextRe = new RegExp('(' + _polyfillHostContext + _parenSuffix, 'gim'); +const _cssColonHostRe = new RegExp(_polyfillHost + _parenSuffix, 'gim'); +const _cssColonHostContextReGlobal = new RegExp(_polyfillHostContext + _parenSuffix, 'gim'); +const _cssColonHostContextRe = new RegExp(_polyfillHostContext + _parenSuffix, 'im'); const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator'; const _polyfillHostNoCombinatorRe = /-shadowcsshost-no-combinator([^\s]*)/; const _shadowDOMSelectorsRe = [ @@ -650,3 +653,51 @@ function escapeBlocks( } return new StringWithEscapedBlocks(resultParts.join(''), escapedBlocks); } + +/** + * Combine the `contextSelectors` with the `hostMarker` and the `otherSelectors` + * to create a selector that matches the same as `:host-context()`. + * + * Given a single context selector `A` we need to output selectors that match on the host and as an + * ancestor of the host: + * + * ``` + * A , A {} + * ``` + * + * When there is more than one context selector we also have to create combinations of those + * selectors with each other. For example if there are `A` and `B` selectors the output is: + * + * ``` + * AB, AB , A B, + * B A, A B , B A {} + * ``` + * + * And so on... + * + * @param hostMarker the string that selects the host element. + * @param contextSelectors an array of context selectors that will be combined. + * @param otherSelectors the rest of the selectors that are not context selectors. + */ +function combineHostContextSelectors( + hostMarker: string, contextSelectors: string[], otherSelectors: string): string { + const combined: string[] = [contextSelectors.pop() || '']; + while (contextSelectors.length > 0) { + const length = combined.length; + const contextSelector = contextSelectors.pop(); + for (let i = 0; i < length; i++) { + const previousSelectors = combined[i]; + // Add the new selector as a descendant of the previous selectors + combined[length * 2 + i] = previousSelectors + ' ' + contextSelector; + // Add the new selector as an ancestor of the previous selectors + combined[length + i] = contextSelector + ' ' + previousSelectors; + // Add the new selector to act on the same element as the previous selectors + combined[i] = contextSelector + previousSelectors; + } + } + // Finally connect the selector to the `hostMarker`s: either acting directly on the host + // (A) or as an ancestor (A ). + return combined + .map(s => `${s}${hostMarker}${otherSelectors}, ${s} ${hostMarker}${otherSelectors}`) + .join(','); +} diff --git a/packages/compiler/test/shadow_css_spec.ts b/packages/compiler/test/shadow_css_spec.ts index 5b9ba51881..626077a551 100644 --- a/packages/compiler/test/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css_spec.ts @@ -145,6 +145,10 @@ import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util'; .toEqual('ul[a-host] > .z[contenta], li[a-host] > .z[contenta] {}'); }); + it('should handle compound class selectors', () => { + expect(s(':host(.a.b) {}', 'contenta', 'a-host')).toEqual('.a.b[a-host] {}'); + }); + it('should handle multiple class selectors', () => { expect(s(':host(.x,.y) {}', 'contenta', 'a-host')).toEqual('.x[a-host], .y[a-host] {}'); expect(s(':host(.x,.y) > .z {}', 'contenta', 'a-host')) @@ -208,6 +212,42 @@ import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util'; expect(s(':host-context([a=b]) {}', 'contenta', 'a-host')) .toEqual('[a=b][a-host], [a="b"] [a-host] {}'); }); + + it('should handle multiple :host-context() selectors', () => { + expect(s(':host-context(.one):host-context(.two) {}', 'contenta', 'a-host')) + .toEqual( + '.one.two[a-host], ' + // `one` and `two` both on the host + '.one.two [a-host], ' + // `one` and `two` are both on the same ancestor + '.one .two[a-host], ' + // `one` is an ancestor and `two` is on the host + '.one .two [a-host], ' + // `one` and `two` are both ancestors (in that order) + '.two .one[a-host], ' + // `two` is an ancestor and `one` is on the host + '.two .one [a-host]' + // `two` and `one` are both ancestors (in that order) + ' {}'); + + expect(s(':host-context(.X):host-context(.Y):host-context(.Z) {}', 'contenta', 'a-host') + .replace(/ \{\}$/, '') + .split(/\,\s+/)) + .toEqual([ + '.X.Y.Z[a-host]', + '.X.Y.Z [a-host]', + '.X.Y .Z[a-host]', + '.X.Y .Z [a-host]', + '.X.Z .Y[a-host]', + '.X.Z .Y [a-host]', + '.X .Y.Z[a-host]', + '.X .Y.Z [a-host]', + '.X .Y .Z[a-host]', + '.X .Y .Z [a-host]', + '.X .Z .Y[a-host]', + '.X .Z .Y [a-host]', + '.Y.Z .X[a-host]', + '.Y.Z .X [a-host]', + '.Y .Z .X[a-host]', + '.Y .Z .X [a-host]', + '.Z .Y .X[a-host]', + '.Z .Y .X [a-host]', + ]); + }); }); it('should support polyfill-next-selector', () => {