Skip to content

Commit 11692c8

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 48a1437 commit 11692c8

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
@@ -338,7 +338,7 @@ export class ShadowCss {
338338
* captures how many (if any) leading whitespaces are present or a comma
339339
* - (?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*))
340340
* captures two different possible keyframes, ones which are quoted or ones which are valid css
341-
* idents (custom properties excluded)
341+
* indents (custom properties excluded)
342342
* - (?=[,\s;]|$)
343343
* simply matches the end of the possible keyframe, valid endings are: a comma, a space, a
344344
* semicolon or the end of the string
@@ -459,7 +459,7 @@ export class ShadowCss {
459459
*/
460460
private _scopeCssText(cssText: string, scopeSelector: string, hostSelector: string): string {
461461
const unscopedRules = this._extractUnscopedRulesFromCssText(cssText);
462-
// replace :host and :host-context -shadowcsshost and -shadowcsshost respectively
462+
// replace :host and :host-context with -shadowcsshost and -shadowcsshostcontext respectively
463463
cssText = this._insertPolyfillHostInCssText(cssText);
464464
cssText = this._convertColonHost(cssText);
465465
cssText = this._convertColonHostContext(cssText);
@@ -616,7 +616,12 @@ export class ShadowCss {
616616
let selector = rule.selector;
617617
let content = rule.content;
618618
if (rule.selector[0] !== '@') {
619-
selector = this._scopeSelector({selector, scopeSelector, hostSelector});
619+
selector = this._scopeSelector({
620+
selector,
621+
scopeSelector,
622+
hostSelector,
623+
isParentSelector: true,
624+
});
620625
} else if (scopedAtRuleIdentifiers.some((atRule) => rule.selector.startsWith(atRule))) {
621626
content = this._scopeSelectors(rule.content, scopeSelector, hostSelector);
622627
} else if (rule.selector.startsWith('@font-face') || rule.selector.startsWith('@page')) {
@@ -656,20 +661,30 @@ export class ShadowCss {
656661
});
657662
}
658663

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

674689
return selector
675690
.split(selectorSplitRe)
@@ -682,7 +697,7 @@ export class ShadowCss {
682697
selector: shallowPart,
683698
scopeSelector,
684699
hostSelector,
685-
shouldScope,
700+
isParentSelector,
686701
});
687702
} else {
688703
return shallowPart;
@@ -716,9 +731,9 @@ export class ShadowCss {
716731
if (_polyfillHostRe.test(selector)) {
717732
const replaceBy = `[${hostSelector}]`;
718733
return selector
719-
.replace(_polyfillHostNoCombinatorRe, (hnc, selector) => {
734+
.replace(_polyfillHostNoCombinatorReGlobal, (_hnc, selector) => {
720735
return selector.replace(
721-
/([^:]*)(:*)(.*)/,
736+
/([^:\)]*)(:*)(.*)/,
722737
(_: string, before: string, colon: string, after: string) => {
723738
return before + replaceBy + colon + after;
724739
},
@@ -736,12 +751,12 @@ export class ShadowCss {
736751
selector,
737752
scopeSelector,
738753
hostSelector,
739-
shouldScope,
754+
isParentSelector,
740755
}: {
741756
selector: string;
742757
scopeSelector: string;
743758
hostSelector: string;
744-
shouldScope?: boolean;
759+
isParentSelector?: boolean;
745760
}): string {
746761
const isRe = /\[is=([^\]]*)\]/g;
747762
scopeSelector = scopeSelector.replace(isRe, (_: string, ...parts: string[]) => parts[0]);
@@ -757,6 +772,10 @@ export class ShadowCss {
757772

758773
if (p.includes(_polyfillHostNoCombinator)) {
759774
scopedP = this._applySimpleSelectorScope(p, scopeSelector, hostSelector);
775+
if (_polyfillHostNoCombinatorWithinPseudoFunction.test(p)) {
776+
const [_, before, colon, after] = scopedP.match(/([^:]*)(:*)(.*)/)!;
777+
scopedP = before + attrName + colon + after;
778+
}
760779
} else {
761780
// remove :host since it should be unnecessary
762781
const t = p.replace(_polyfillHostRe, '');
@@ -773,44 +792,66 @@ export class ShadowCss {
773792

774793
// Wraps `_scopeSelectorPart()` to not use it directly on selectors with
775794
// pseudo selector functions like `:where()`. Selectors within pseudo selector
776-
// functions are recursively sent to `_scopeSelector()` with the `shouldScope`
777-
// argument, so the selectors get scoped correctly.
795+
// functions are recursively sent to `_scopeSelector()`.
778796
const _pseudoFunctionAwareScopeSelectorPart = (selectorPart: string) => {
779797
let scopedPart = '';
780798

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

817+
if (selectorToScope.includes(_polyfillHostNoCombinator)) {
818+
this._shouldScopeIndicator = true;
819+
}
820+
788821
const scopedInnerPart = this._scopeSelector({
789822
selector: selectorToScope,
790823
scopeSelector,
791824
hostSelector,
792-
shouldScope: shouldScopeIndicator,
793825
});
826+
794827
// Put the result back into the pseudo selector function.
795-
scopedPart = `${cssPseudoSelectorFunction}${scopedInnerPart})`;
828+
scopedPart = `${scopedMainSelector}:${pseudoSelector}(${scopedInnerPart})`;
829+
830+
this._shouldScopeIndicator = this._shouldScopeIndicator || hasOuterHostNoCombinator;
796831
} else {
797-
shouldScopeIndicator =
798-
shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator);
799-
scopedPart = shouldScopeIndicator ? _scopeSelectorPart(selectorPart) : selectorPart;
832+
this._shouldScopeIndicator =
833+
this._shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator);
834+
scopedPart = this._shouldScopeIndicator ? _scopeSelectorPart(selectorPart) : selectorPart;
800835
}
801836

802837
return scopedPart;
803838
};
804839

805-
const safeContent = new SafeSelector(selector);
806-
selector = safeContent.content();
840+
if (isParentSelector) {
841+
this._safeSelector = new SafeSelector(selector);
842+
selector = this._safeSelector.content();
843+
}
807844

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

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

830876
while ((res = sep.exec(selector)) !== null) {
831877
const separator = res[1];
@@ -853,7 +899,8 @@ export class ShadowCss {
853899
scopedSelector += _pseudoFunctionAwareScopeSelectorPart(part);
854900

855901
// replace the placeholders with their original values
856-
return safeContent.restore(scopedSelector);
902+
// using values stored inside the `safeSelector` instance.
903+
return this._safeSelector!.restore(scopedSelector);
857904
}
858905

859906
private _insertPolyfillHostInCssText(selector: string): string {
@@ -918,7 +965,7 @@ class SafeSelector {
918965
}
919966
}
920967

921-
const _cssPseudoSelectorFunctionPrefix = /^:(where|is)\(/gi;
968+
const _cssPrefixWithPseudoSelectorFunction = /^([^:]*):(where|is)\(/i;
922969
const _cssContentNextSelectorRe =
923970
/polyfill-next-selector[^}]*content:[\s]*?(['"])(.*?)\1[;\s]*}([^{]*?){/gim;
924971
const _cssContentRuleRe = /(polyfill-rule)[^}]*(content:[\s]*(['"])(.*?)\3)[;\s]*[^}]*}/gim;
@@ -932,7 +979,11 @@ const _cssColonHostRe = new RegExp(_polyfillHost + _parenSuffix, 'gim');
932979
const _cssColonHostContextReGlobal = new RegExp(_polyfillHostContext + _parenSuffix, 'gim');
933980
const _cssColonHostContextRe = new RegExp(_polyfillHostContext + _parenSuffix, 'im');
934981
const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator';
982+
const _polyfillHostNoCombinatorWithinPseudoFunction = new RegExp(
983+
`:.*(.*${_polyfillHostNoCombinator}.*)`,
984+
);
935985
const _polyfillHostNoCombinatorRe = /-shadowcsshost-no-combinator([^\s]*)/;
986+
const _polyfillHostNoCombinatorReGlobal = new RegExp(_polyfillHostNoCombinatorRe, 'g');
936987
const _shadowDOMSelectorsRe = [
937988
/::shadow/g,
938989
/::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)