Skip to content

Commit 7a6fd42

Browse files
GeorgySergadevversion
authored andcommitted
fix(compiler): transform pseudo selectors correctly for the encapsulated view (#57796)
fix scoping and transforming logic of the `shimCssText` for the components with encapsulated view: - add support for pseudo selector functions - apply content scoping for inner selectors of `:is()` and `:where()` - allow multiple comma separated selectors inside pseudo selectors Fixes #45686 PR Close #57796
1 parent 31fb9d0 commit 7a6fd42

File tree

2 files changed

+151
-15
lines changed

2 files changed

+151
-15
lines changed

packages/compiler/src/shadow_css.ts

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -616,7 +616,7 @@ export class ShadowCss {
616616
let selector = rule.selector;
617617
let content = rule.content;
618618
if (rule.selector[0] !== '@') {
619-
selector = this._scopeSelector(rule.selector, scopeSelector, hostSelector);
619+
selector = this._scopeSelector({selector, scopeSelector, hostSelector});
620620
} else if (scopedAtRuleIdentifiers.some((atRule) => rule.selector.startsWith(atRule))) {
621621
content = this._scopeSelectors(rule.content, scopeSelector, hostSelector);
622622
} else if (rule.selector.startsWith('@font-face') || rule.selector.startsWith('@page')) {
@@ -656,15 +656,34 @@ export class ShadowCss {
656656
});
657657
}
658658

659-
private _scopeSelector(selector: string, scopeSelector: string, hostSelector: string): string {
659+
private _scopeSelector({
660+
selector,
661+
scopeSelector,
662+
hostSelector,
663+
shouldScope,
664+
}: {
665+
selector: string;
666+
scopeSelector: string;
667+
hostSelector: string;
668+
shouldScope?: boolean;
669+
}): string {
670+
// Split the selector into independent parts by `,` (comma) unless
671+
// comma is within parenthesis, for example `:is(.one, two)`.
672+
const selectorSplitRe = / ?,(?![^\(]*\)) ?/;
673+
660674
return selector
661-
.split(/ ?, ?/)
675+
.split(selectorSplitRe)
662676
.map((part) => part.split(_shadowDeepSelectors))
663677
.map((deepParts) => {
664678
const [shallowPart, ...otherParts] = deepParts;
665679
const applyScope = (shallowPart: string) => {
666680
if (this._selectorNeedsScoping(shallowPart, scopeSelector)) {
667-
return this._applySelectorScope(shallowPart, scopeSelector, hostSelector);
681+
return this._applySelectorScope({
682+
selector: shallowPart,
683+
scopeSelector,
684+
hostSelector,
685+
shouldScope,
686+
});
668687
} else {
669688
return shallowPart;
670689
}
@@ -713,11 +732,17 @@ export class ShadowCss {
713732

714733
// return a selector with [name] suffix on each simple selector
715734
// e.g. .foo.bar > .zot becomes .foo[name].bar[name] > .zot[name] /** @internal */
716-
private _applySelectorScope(
717-
selector: string,
718-
scopeSelector: string,
719-
hostSelector: string,
720-
): string {
735+
private _applySelectorScope({
736+
selector,
737+
scopeSelector,
738+
hostSelector,
739+
shouldScope,
740+
}: {
741+
selector: string;
742+
scopeSelector: string;
743+
hostSelector: string;
744+
shouldScope?: boolean;
745+
}): string {
721746
const isRe = /\[is=([^\]]*)\]/g;
722747
scopeSelector = scopeSelector.replace(isRe, (_: string, ...parts: string[]) => parts[0]);
723748

@@ -746,13 +771,46 @@ export class ShadowCss {
746771
return scopedP;
747772
};
748773

774+
// Wraps `_scopeSelectorPart()` to not use it directly on selectors with
775+
// 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.
778+
const _pseudoFunctionAwareScopeSelectorPart = (selectorPart: string) => {
779+
let scopedPart = '';
780+
781+
const cssPseudoSelectorFunctionMatch = selectorPart.match(_cssPseudoSelectorFunctionPrefix);
782+
if (cssPseudoSelectorFunctionMatch) {
783+
const [cssPseudoSelectorFunction] = cssPseudoSelectorFunctionMatch;
784+
// Unwrap the pseudo selector, to scope its contents.
785+
// For example, `:where(selectorToScope)` -> `selectorToScope`.
786+
const selectorToScope = selectorPart.slice(cssPseudoSelectorFunction.length, -1);
787+
788+
const scopedInnerPart = this._scopeSelector({
789+
selector: selectorToScope,
790+
scopeSelector,
791+
hostSelector,
792+
shouldScope: shouldScopeIndicator,
793+
});
794+
// Put the result back into the pseudo selector function.
795+
scopedPart = `${cssPseudoSelectorFunction}${scopedInnerPart})`;
796+
} else {
797+
shouldScopeIndicator =
798+
shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator);
799+
scopedPart = shouldScopeIndicator ? _scopeSelectorPart(selectorPart) : selectorPart;
800+
}
801+
802+
return scopedPart;
803+
};
804+
749805
const safeContent = new SafeSelector(selector);
750806
selector = safeContent.content();
751807

752808
let scopedSelector = '';
753809
let startIndex = 0;
754810
let res: RegExpExecArray | null;
755-
const sep = /( |>|\+|~(?!=))\s*/g;
811+
// Spaces aren't used as a delimeter if they are within parenthesis, for example
812+
// `:where(.one .two)` stays intact.
813+
const sep = /( (?![^\(]*\))|>|\+|~(?!=))\s*/g;
756814

757815
// If a selector appears before :host it should not be shimmed as it
758816
// matches on ancestor elements and not on elements in the host's shadow
@@ -767,7 +825,7 @@ export class ShadowCss {
767825
// `:host-context(tag)`)
768826
const hasHost = selector.includes(_polyfillHostNoCombinator);
769827
// Only scope parts after the first `-shadowcsshost-no-combinator` when it is present
770-
let shouldScope = !hasHost;
828+
let shouldScopeIndicator = shouldScope ?? !hasHost;
771829

772830
while ((res = sep.exec(selector)) !== null) {
773831
const separator = res[1];
@@ -786,15 +844,13 @@ export class ShadowCss {
786844
continue;
787845
}
788846

789-
shouldScope = shouldScope || part.includes(_polyfillHostNoCombinator);
790-
const scopedPart = shouldScope ? _scopeSelectorPart(part) : part;
847+
const scopedPart = _pseudoFunctionAwareScopeSelectorPart(part);
791848
scopedSelector += `${scopedPart} ${separator} `;
792849
startIndex = sep.lastIndex;
793850
}
794851

795852
const part = selector.substring(startIndex);
796-
shouldScope = shouldScope || part.includes(_polyfillHostNoCombinator);
797-
scopedSelector += shouldScope ? _scopeSelectorPart(part) : part;
853+
scopedSelector += _pseudoFunctionAwareScopeSelectorPart(part);
798854

799855
// replace the placeholders with their original values
800856
return safeContent.restore(scopedSelector);
@@ -862,6 +918,7 @@ class SafeSelector {
862918
}
863919
}
864920

921+
const _cssPseudoSelectorFunctionPrefix = /^:(where|is)\(/gi;
865922
const _cssContentNextSelectorRe =
866923
/polyfill-next-selector[^}]*content:[\s]*?(['"])(.*?)\1[;\s]*}([^{]*?){/gim;
867924
const _cssContentRuleRe = /(polyfill-rule)[^}]*(content:[\s]*(['"])(.*?)\3)[;\s]*[^}]*}/gim;

packages/compiler/test/shadow_css/shadow_css_spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,85 @@ describe('ShadowCss', () => {
7979
);
8080
});
8181

82+
it('should handle pseudo functions correctly', () => {
83+
// :where()
84+
expect(shim(':where(.one) {}', 'contenta', 'hosta')).toEqualCss(':where(.one[contenta]) {}');
85+
expect(shim(':where(div.one span.two) {}', 'contenta', 'hosta')).toEqualCss(
86+
':where(div.one[contenta] span.two[contenta]) {}',
87+
);
88+
expect(shim(':where(.one) .two {}', 'contenta', 'hosta')).toEqualCss(
89+
':where(.one[contenta]) .two[contenta] {}',
90+
);
91+
expect(shim(':where(:host) {}', 'contenta', 'hosta')).toEqualCss(':where([hosta]) {}');
92+
expect(shim(':where(.one) :where(:host) {}', 'contenta', 'hosta')).toEqualCss(
93+
':where(.one) :where([hosta]) {}',
94+
);
95+
expect(shim(':where(.one :host) {}', 'contenta', 'hosta')).toEqualCss(
96+
':where(.one [hosta]) {}',
97+
);
98+
expect(shim('div :where(.one) {}', 'contenta', 'hosta')).toEqualCss(
99+
'div[contenta] :where(.one[contenta]) {}',
100+
);
101+
expect(shim(':host :where(.one .two) {}', 'contenta', 'hosta')).toEqualCss(
102+
'[hosta] :where(.one[contenta] .two[contenta]) {}',
103+
);
104+
expect(shim(':where(.one, .two) {}', 'contenta', 'hosta')).toEqualCss(
105+
':where(.one[contenta], .two[contenta]) {}',
106+
);
107+
108+
// :is()
109+
expect(shim('div:is(.foo) {}', 'contenta', 'a-host')).toEqualCss('div[contenta]:is(.foo) {}');
110+
expect(shim(':is(.dark :host) {}', 'contenta', 'a-host')).toEqualCss(':is(.dark [a-host]) {}');
111+
expect(shim(':host:is(.foo) {}', 'contenta', 'a-host')).toEqualCss('[a-host]:is(.foo) {}');
112+
expect(shim(':is(.foo) {}', 'contenta', 'a-host')).toEqualCss(':is(.foo[contenta]) {}');
113+
expect(shim(':is(.foo, .bar, .baz) {}', 'contenta', 'a-host')).toEqualCss(
114+
':is(.foo[contenta], .bar[contenta], .baz[contenta]) {}',
115+
);
116+
expect(shim(':is(.foo, .bar) :host {}', 'contenta', 'a-host')).toEqualCss(
117+
':is(.foo, .bar) [a-host] {}',
118+
);
119+
120+
// :is() and :where()
121+
expect(
122+
shim(
123+
':is(.foo, .bar) :is(.baz) :where(.one, .two) :host :where(.three:first-child) {}',
124+
'contenta',
125+
'a-host',
126+
),
127+
).toEqualCss(
128+
':is(.foo, .bar) :is(.baz) :where(.one, .two) [a-host] :where(.three[contenta]:first-child) {}',
129+
);
130+
131+
// complex selectors
132+
expect(shim(':host:is([foo],[foo-2])>div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
133+
'[a-host]:is([foo],[foo-2]) > div.example-2[contenta] {}',
134+
);
135+
expect(shim(':host:is([foo], [foo-2]) > div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
136+
'[a-host]:is([foo], [foo-2]) > div.example-2[contenta] {}',
137+
);
138+
expect(shim(':host:has([foo],[foo-2])>div.example-2 {}', 'contenta', 'a-host')).toEqualCss(
139+
'[a-host]:has([foo],[foo-2]) > div.example-2[contenta] {}',
140+
);
141+
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] {}',
143+
);
144+
145+
// :has()
146+
expect(shim('div:has(a) {}', 'contenta', 'hosta')).toEqualCss('div[contenta]:has(a) {}');
147+
expect(shim('div:has(a) :host {}', 'contenta', 'hosta')).toEqualCss('div:has(a) [hosta] {}');
148+
expect(shim(':has(a) :host :has(b) {}', 'contenta', 'hosta')).toEqualCss(
149+
':has(a) [hosta] [contenta]:has(b) {}',
150+
);
151+
// Unlike `:is()` or `:where()` the attribute selector isn't placed inside
152+
// of `:has()`. That is deliberate, `[contenta]:has(a)` would select all
153+
// `[contenta]` with `a` inside, while `:has(a[contenta])` would select
154+
// everything that contains `a[contenta]`, targeting elements outside of
155+
// encapsulated scope.
156+
expect(shim(':has(a) :has(b) {}', 'contenta', 'hosta')).toEqualCss(
157+
'[contenta]:has(a) [contenta]:has(b) {}',
158+
);
159+
});
160+
82161
it('should handle escaped selector with space (if followed by a hex char)', () => {
83162
// When esbuild runs with optimization.minify
84163
// selectors are escaped: .über becomes .\fc ber.

0 commit comments

Comments
 (0)