Skip to content

Commit e8d1944

Browse files
GeorgySergadevversion
authored andcommitted
fix(compiler): add multiple :host and nested selectors support (#57796)
add support for nested and deeply nested (up to three levels) selectors, parse multiple :host selectors, scope selectors within pseudo functions PR Close #57796
1 parent 292ea47 commit e8d1944

File tree

2 files changed

+140
-34
lines changed

2 files changed

+140
-34
lines changed

packages/compiler/src/shadow_css.ts

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export class ShadowCss {
340340
* captures how many (if any) leading whitespaces are present or a comma
341341
* - (?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*))
342342
* captures two different possible keyframes, ones which are quoted or ones which are valid css
343-
* idents (custom properties excluded)
343+
* indents (custom properties excluded)
344344
* - (?=[,\s;]|$)
345345
* simply matches the end of the possible keyframe, valid endings are: a comma, a space, a
346346
* semicolon or the end of the string
@@ -461,7 +461,7 @@ export class ShadowCss {
461461
*/
462462
private _scopeCssText(cssText: string, scopeSelector: string, hostSelector: string): string {
463463
const unscopedRules = this._extractUnscopedRulesFromCssText(cssText);
464-
// replace :host and :host-context -shadowcsshost and -shadowcsshost respectively
464+
// replace :host and :host-context with -shadowcsshost and -shadowcsshostcontext respectively
465465
cssText = this._insertPolyfillHostInCssText(cssText);
466466
cssText = this._convertColonHost(cssText);
467467
cssText = this._convertColonHostContext(cssText);
@@ -618,7 +618,12 @@ export class ShadowCss {
618618
let selector = rule.selector;
619619
let content = rule.content;
620620
if (rule.selector[0] !== '@') {
621-
selector = this._scopeSelector({selector, scopeSelector, hostSelector});
621+
selector = this._scopeSelector({
622+
selector,
623+
scopeSelector,
624+
hostSelector,
625+
isParentSelector: true,
626+
});
622627
} else if (scopedAtRuleIdentifiers.some((atRule) => rule.selector.startsWith(atRule))) {
623628
content = this._scopeSelectors(rule.content, scopeSelector, hostSelector);
624629
} else if (rule.selector.startsWith('@font-face') || rule.selector.startsWith('@page')) {
@@ -658,20 +663,30 @@ export class ShadowCss {
658663
});
659664
}
660665

666+
private _safeSelector: SafeSelector | undefined;
667+
private _shouldScopeIndicator: boolean | undefined;
668+
669+
// `isParentSelector` is used to distinguish the selectors which are coming from
670+
// the initial selector string and any nested selectors, parsed recursively,
671+
// for example `selector = 'a:where(.one)'` could be the parent, while recursive call
672+
// would have `selector = '.one'`.
661673
private _scopeSelector({
662674
selector,
663675
scopeSelector,
664676
hostSelector,
665-
shouldScope,
677+
isParentSelector = false,
666678
}: {
667679
selector: string;
668680
scopeSelector: string;
669681
hostSelector: string;
670-
shouldScope?: boolean;
682+
isParentSelector?: boolean;
671683
}): string {
672684
// Split the selector into independent parts by `,` (comma) unless
673685
// comma is within parenthesis, for example `:is(.one, two)`.
674-
const selectorSplitRe = / ?,(?![^\(]*\)) ?/;
686+
// Negative lookup after comma allows not splitting inside nested parenthesis,
687+
// up to three levels (((,))).
688+
const selectorSplitRe =
689+
/ ?,(?!(?:[^)(]*(?:\([^)(]*(?:\([^)(]*(?:\([^)(]*\)[^)(]*)*\)[^)(]*)*\)[^)(]*)*\))) ?/;
675690

676691
return selector
677692
.split(selectorSplitRe)
@@ -684,7 +699,7 @@ export class ShadowCss {
684699
selector: shallowPart,
685700
scopeSelector,
686701
hostSelector,
687-
shouldScope,
702+
isParentSelector,
688703
});
689704
} else {
690705
return shallowPart;
@@ -718,9 +733,9 @@ export class ShadowCss {
718733
if (_polyfillHostRe.test(selector)) {
719734
const replaceBy = `[${hostSelector}]`;
720735
return selector
721-
.replace(_polyfillHostNoCombinatorRe, (hnc, selector) => {
736+
.replace(_polyfillHostNoCombinatorReGlobal, (_hnc, selector) => {
722737
return selector.replace(
723-
/([^:]*)(:*)(.*)/,
738+
/([^:\)]*)(:*)(.*)/,
724739
(_: string, before: string, colon: string, after: string) => {
725740
return before + replaceBy + colon + after;
726741
},
@@ -738,12 +753,12 @@ export class ShadowCss {
738753
selector,
739754
scopeSelector,
740755
hostSelector,
741-
shouldScope,
756+
isParentSelector,
742757
}: {
743758
selector: string;
744759
scopeSelector: string;
745760
hostSelector: string;
746-
shouldScope?: boolean;
761+
isParentSelector?: boolean;
747762
}): string {
748763
const isRe = /\[is=([^\]]*)\]/g;
749764
scopeSelector = scopeSelector.replace(isRe, (_: string, ...parts: string[]) => parts[0]);
@@ -759,6 +774,10 @@ export class ShadowCss {
759774

760775
if (p.includes(_polyfillHostNoCombinator)) {
761776
scopedP = this._applySimpleSelectorScope(p, scopeSelector, hostSelector);
777+
if (_polyfillHostNoCombinatorWithinPseudoFunction.test(p)) {
778+
const [_, before, colon, after] = scopedP.match(/([^:]*)(:*)(.*)/)!;
779+
scopedP = before + attrName + colon + after;
780+
}
762781
} else {
763782
// remove :host since it should be unnecessary
764783
const t = p.replace(_polyfillHostRe, '');
@@ -775,44 +794,66 @@ export class ShadowCss {
775794

776795
// Wraps `_scopeSelectorPart()` to not use it directly on selectors with
777796
// pseudo selector functions like `:where()`. Selectors within pseudo selector
778-
// functions are recursively sent to `_scopeSelector()` with the `shouldScope`
779-
// argument, so the selectors get scoped correctly.
797+
// functions are recursively sent to `_scopeSelector()`.
780798
const _pseudoFunctionAwareScopeSelectorPart = (selectorPart: string) => {
781799
let scopedPart = '';
782800

783-
const cssPseudoSelectorFunctionMatch = selectorPart.match(_cssPseudoSelectorFunctionPrefix);
784-
if (cssPseudoSelectorFunctionMatch) {
785-
const [cssPseudoSelectorFunction] = cssPseudoSelectorFunctionMatch;
801+
const cssPrefixWithPseudoSelectorFunctionMatch = selectorPart.match(
802+
_cssPrefixWithPseudoSelectorFunction,
803+
);
804+
if (cssPrefixWithPseudoSelectorFunctionMatch) {
805+
const [cssPseudoSelectorFunction, mainSelector, pseudoSelector] =
806+
cssPrefixWithPseudoSelectorFunctionMatch;
807+
const hasOuterHostNoCombinator = mainSelector.includes(_polyfillHostNoCombinator);
808+
const scopedMainSelector = mainSelector.replace(
809+
_polyfillHostNoCombinatorReGlobal,
810+
`[${hostSelector}]`,
811+
);
812+
786813
// Unwrap the pseudo selector, to scope its contents.
787-
// For example, `:where(selectorToScope)` -> `selectorToScope`.
814+
// For example,
815+
// - `:where(selectorToScope)` -> `selectorToScope`;
816+
// - `div:is(.foo, .bar)` -> `.foo, .bar`.
788817
const selectorToScope = selectorPart.slice(cssPseudoSelectorFunction.length, -1);
789818

819+
if (selectorToScope.includes(_polyfillHostNoCombinator)) {
820+
this._shouldScopeIndicator = true;
821+
}
822+
790823
const scopedInnerPart = this._scopeSelector({
791824
selector: selectorToScope,
792825
scopeSelector,
793826
hostSelector,
794-
shouldScope: shouldScopeIndicator,
795827
});
828+
796829
// Put the result back into the pseudo selector function.
797-
scopedPart = `${cssPseudoSelectorFunction}${scopedInnerPart})`;
830+
scopedPart = `${scopedMainSelector}:${pseudoSelector}(${scopedInnerPart})`;
831+
832+
this._shouldScopeIndicator = this._shouldScopeIndicator || hasOuterHostNoCombinator;
798833
} else {
799-
shouldScopeIndicator =
800-
shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator);
801-
scopedPart = shouldScopeIndicator ? _scopeSelectorPart(selectorPart) : selectorPart;
834+
this._shouldScopeIndicator =
835+
this._shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator);
836+
scopedPart = this._shouldScopeIndicator ? _scopeSelectorPart(selectorPart) : selectorPart;
802837
}
803838

804839
return scopedPart;
805840
};
806841

807-
const safeContent = new SafeSelector(selector);
808-
selector = safeContent.content();
842+
if (isParentSelector) {
843+
this._safeSelector = new SafeSelector(selector);
844+
selector = this._safeSelector.content();
845+
}
809846

810847
let scopedSelector = '';
811848
let startIndex = 0;
812849
let res: RegExpExecArray | null;
813850
// Combinators aren't used as a delimiter if they are within parenthesis,
814851
// for example `:where(.one .two)` stays intact.
815-
const sep = /( |>|\+|~(?!=))(?![^\(]*\))\s*/g;
852+
// Similarly to selector separation by comma initially, negative lookahead
853+
// is used here to not break selectors within nested parenthesis up to three
854+
// nested layers.
855+
const sep =
856+
/( |>|\+|~(?!=))(?!([^)(]*(?:\([^)(]*(?:\([^)(]*(?:\([^)(]*\)[^)(]*)*\)[^)(]*)*\)[^)(]*)*\)))\s*/g;
816857

817858
// If a selector appears before :host it should not be shimmed as it
818859
// matches on ancestor elements and not on elements in the host's shadow
@@ -826,8 +867,13 @@ export class ShadowCss {
826867
// - `tag :host` -> `tag [h]` (`tag` is not scoped because it's considered part of a
827868
// `:host-context(tag)`)
828869
const hasHost = selector.includes(_polyfillHostNoCombinator);
829-
// Only scope parts after the first `-shadowcsshost-no-combinator` when it is present
830-
let shouldScopeIndicator = shouldScope ?? !hasHost;
870+
// Only scope parts after or on the same level as the first `-shadowcsshost-no-combinator`
871+
// when it is present. The selector has the same level when it is a part of a pseudo
872+
// selector, like `:where()`, for example `:where(:host, .foo)` would result in `.foo`
873+
// being scoped.
874+
if (isParentSelector || this._shouldScopeIndicator) {
875+
this._shouldScopeIndicator = !hasHost;
876+
}
831877

832878
while ((res = sep.exec(selector)) !== null) {
833879
const separator = res[1];
@@ -855,7 +901,8 @@ export class ShadowCss {
855901
scopedSelector += _pseudoFunctionAwareScopeSelectorPart(part);
856902

857903
// replace the placeholders with their original values
858-
return safeContent.restore(scopedSelector);
904+
// using values stored inside the `safeSelector` instance.
905+
return this._safeSelector!.restore(scopedSelector);
859906
}
860907

861908
private _insertPolyfillHostInCssText(selector: string): string {
@@ -920,7 +967,7 @@ class SafeSelector {
920967
}
921968
}
922969

923-
const _cssPseudoSelectorFunctionPrefix = /^:(where|is)\(/gi;
970+
const _cssPrefixWithPseudoSelectorFunction = /^([^:]*):(where|is)\(/i;
924971
const _cssContentNextSelectorRe =
925972
/polyfill-next-selector[^}]*content:[\s]*?(['"])(.*?)\1[;\s]*}([^{]*?){/gim;
926973
const _cssContentRuleRe = /(polyfill-rule)[^}]*(content:[\s]*(['"])(.*?)\3)[;\s]*[^}]*}/gim;
@@ -934,7 +981,11 @@ const _cssColonHostRe = new RegExp(_polyfillHost + _parenSuffix, 'gim');
934981
const _cssColonHostContextReGlobal = new RegExp(_polyfillHostContext + _parenSuffix, 'gim');
935982
const _cssColonHostContextRe = new RegExp(_polyfillHostContext + _parenSuffix, 'im');
936983
const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator';
984+
const _polyfillHostNoCombinatorWithinPseudoFunction = new RegExp(
985+
`:.*(.*${_polyfillHostNoCombinator}.*)`,
986+
);
937987
const _polyfillHostNoCombinatorRe = /-shadowcsshost-no-combinator([^\s]*)/;
988+
const _polyfillHostNoCombinatorReGlobal = new RegExp(_polyfillHostNoCombinatorRe, 'g');
938989
const _shadowDOMSelectorsRe = [
939990
/::shadow/g,
940991
/::content/g,

packages/compiler/test/shadow_css/shadow_css_spec.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ describe('ShadowCss', () => {
6666
expect(shim('one[attr="va lue"] {}', 'contenta')).toEqualCss('one[attr="va lue"][contenta] {}');
6767
expect(shim('one[attr] {}', 'contenta')).toEqualCss('one[attr][contenta] {}');
6868
expect(shim('[is="one"] {}', 'contenta')).toEqualCss('[is="one"][contenta] {}');
69+
expect(shim('[attr] {}', 'contenta')).toEqualCss('[attr][contenta] {}');
70+
expect(shim(':host [attr] {}', 'contenta', 'hosta')).toEqualCss('[hosta] [attr][contenta] {}');
6971
});
7072

7173
it('should handle escaped sequences in selectors', () => {
@@ -89,6 +91,9 @@ describe('ShadowCss', () => {
8991
':where(.one[contenta]) .two[contenta] {}',
9092
);
9193
expect(shim(':where(:host) {}', 'contenta', 'hosta')).toEqualCss(':where([hosta]) {}');
94+
expect(shim(':where(:host) .one {}', 'contenta', 'hosta')).toEqualCss(
95+
':where([hosta]) .one[contenta] {}',
96+
);
9297
expect(shim(':where(.one) :where(:host) {}', 'contenta', 'hosta')).toEqualCss(
9398
':where(.one) :where([hosta]) {}',
9499
);
@@ -113,10 +118,14 @@ describe('ShadowCss', () => {
113118
expect(shim(':where(:not(.one) ~ .two) {}', 'contenta', 'hosta')).toEqualCss(
114119
':where([contenta]:not(.one) ~ .two[contenta]) {}',
115120
);
121+
expect(shim(':where([foo]) {}', 'contenta', 'hosta')).toEqualCss(':where([foo][contenta]) {}');
116122

117123
// :is()
118-
expect(shim('div:is(.foo) {}', 'contenta', 'a-host')).toEqualCss('div[contenta]:is(.foo) {}');
124+
expect(shim('div:is(.foo) {}', 'contenta', 'a-host')).toEqualCss('div:is(.foo[contenta]) {}');
119125
expect(shim(':is(.dark :host) {}', 'contenta', 'a-host')).toEqualCss(':is(.dark [a-host]) {}');
126+
expect(shim(':is(.dark) :is(:host) {}', 'contenta', 'a-host')).toEqualCss(
127+
':is(.dark) :is([a-host]) {}',
128+
);
120129
expect(shim(':host:is(.foo) {}', 'contenta', 'a-host')).toEqualCss('[a-host]:is(.foo) {}');
121130
expect(shim(':is(.foo) {}', 'contenta', 'a-host')).toEqualCss(':is(.foo[contenta]) {}');
122131
expect(shim(':is(.foo, .bar, .baz) {}', 'contenta', 'a-host')).toEqualCss(
@@ -136,20 +145,41 @@ describe('ShadowCss', () => {
136145
).toEqualCss(
137146
':is(.foo, .bar) :is(.baz) :where(.one, .two) [a-host] :where(.three[contenta]:first-child) {}',
138147
);
148+
expect(shim(':where(:is(a)) {}', 'contenta', 'hosta')).toEqualCss(
149+
':where(:is(a[contenta])) {}',
150+
);
151+
expect(shim(':where(:is(a, b)) {}', 'contenta', 'hosta')).toEqualCss(
152+
':where(:is(a[contenta], b[contenta])) {}',
153+
);
154+
expect(shim(':where(:host:is(.one, .two)) {}', 'contenta', 'hosta')).toEqualCss(
155+
':where([hosta]:is(.one, .two)) {}',
156+
);
157+
expect(shim(':where(:host :is(.one, .two)) {}', 'contenta', 'hosta')).toEqualCss(
158+
':where([hosta] :is(.one[contenta], .two[contenta])) {}',
159+
);
160+
expect(shim(':where(:is(a, b) :is(.one, .two)) {}', 'contenta', 'hosta')).toEqualCss(
161+
':where(:is(a[contenta], b[contenta]) :is(.one[contenta], .two[contenta])) {}',
162+
);
163+
expect(
164+
shim(
165+
':where(:where(a:has(.foo), b) :is(.one, .two:where(.foo > .bar))) {}',
166+
'contenta',
167+
'hosta',
168+
),
169+
).toEqualCss(
170+
':where(:where(a[contenta]:has(.foo), b[contenta]) :is(.one[contenta], .two:where(.foo[contenta] > .bar[contenta]))) {}',
171+
);
139172

140173
// complex selectors
141174
expect(shim(':host:is([foo],[foo-2])>div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
142-
'[a-host]:is([foo],[foo-2]) > div.example-2[contenta] {}',
175+
'[a-host]:is([foo], [foo-2]) > div.example-2[contenta] {}',
143176
);
144177
expect(shim(':host:is([foo], [foo-2]) > div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
145178
'[a-host]:is([foo], [foo-2]) > div.example-2[contenta] {}',
146179
);
147180
expect(shim(':host:has([foo],[foo-2])>div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
148181
'[a-host]:has([foo],[foo-2]) > div.example-2[contenta] {}',
149182
);
150-
expect(shim(':host:is([foo], [foo-2]) > div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
151-
'[a-host]:is([foo], [foo-2]) > div.example-2[contenta] {}',
152-
);
153183

154184
// :has()
155185
expect(shim('div:has(a) {}', 'contenta', 'hosta')).toEqualCss('div[contenta]:has(a) {}');
@@ -170,6 +200,31 @@ describe('ShadowCss', () => {
170200
);
171201
});
172202

203+
it('should handle :host inclusions inside pseudo-selectors selectors', () => {
204+
expect(shim('.header:not(.admin) {}', 'contenta', 'hosta')).toEqualCss(
205+
'.header[contenta]:not(.admin) {}',
206+
);
207+
expect(shim('.header:is(:host > .toolbar, :host ~ .panel) {}', 'contenta', 'hosta')).toEqualCss(
208+
'.header:is([hosta] > .toolbar[contenta], [hosta] ~ .panel[contenta]) {}',
209+
);
210+
expect(
211+
shim('.header:where(:host > .toolbar, :host ~ .panel) {}', 'contenta', 'hosta'),
212+
).toEqualCss('.header:where([hosta] > .toolbar[contenta], [hosta] ~ .panel[contenta]) {}');
213+
expect(shim('.header:not(.admin, :host.super .header) {}', 'contenta', 'hosta')).toEqualCss(
214+
'.header[contenta]:not(.admin, .super[hosta] .header) {}',
215+
);
216+
expect(
217+
shim('.header:not(.admin, :host.super .header, :host.mega .header) {}', 'contenta', 'hosta'),
218+
).toEqualCss('.header[contenta]:not(.admin, .super[hosta] .header, .mega[hosta] .header) {}');
219+
220+
expect(shim('.one :where(.two, :host) {}', 'contenta', 'hosta')).toEqualCss(
221+
'.one :where(.two[contenta], [hosta]) {}',
222+
);
223+
expect(shim('.one :where(:host, .two) {}', 'contenta', 'hosta')).toEqualCss(
224+
'.one :where([hosta], .two[contenta]) {}',
225+
);
226+
});
227+
173228
it('should handle escaped selector with space (if followed by a hex char)', () => {
174229
// When esbuild runs with optimization.minify
175230
// selectors are escaped: .über becomes .\fc ber.

0 commit comments

Comments
 (0)