Skip to content

Commit 4fd0ecd

Browse files
authored
fix(compiler): correctly generate CSS rules using ::slotted outside shadow DOM (#4969)
* correctly replace `::slotted` selectors when scoping CSS * globally transform all instances of selector * fix boo boo & add unit tests
1 parent d947094 commit 4fd0ecd

5 files changed

+66
-8
lines changed

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './message-utils';
1111
export * from './output-target';
1212
export * from './path';
1313
export * from './query-nonce-meta-tag-content';
14+
export * from './regular-expression';
1415
export * as result from './result';
1516
export * from './sourcemaps';
1617
export * from './url-paths';

src/utils/regular-expression.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Utility function that will escape all regular expression special characters in a string.
3+
*
4+
* @param text The string potentially containing special characters.
5+
* @returns The string with all special characters escaped.
6+
*/
7+
export const escapeRegExpSpecialCharacters = (text: string): string => {
8+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9+
};

src/utils/shadow-css.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
* https://github.com/angular/angular/blob/master/packages/compiler/src/shadow_css.ts
1111
*/
1212

13+
import { escapeRegExpSpecialCharacters } from './regular-expression';
14+
1315
const safeSelector = (selector: string) => {
1416
const placeholders: string[] = [];
1517
let index = 0;
@@ -279,9 +281,9 @@ const convertColonSlotted = (cssText: string, slotScopeId: string) => {
279281
prefixSelector = char + prefixSelector;
280282
}
281283

282-
const orgSelector = prefixSelector + slottedSelector;
283-
const addedSelector = `${prefixSelector.trimRight()}${slottedSelector.trim()}`;
284-
if (orgSelector.trim() !== addedSelector.trim()) {
284+
const orgSelector = (prefixSelector + slottedSelector).trim();
285+
const addedSelector = `${prefixSelector.trimEnd()}${slottedSelector.trim()}`.trim();
286+
if (orgSelector !== addedSelector) {
285287
const updatedSelector = `${addedSelector}, ${orgSelector}`;
286288
selectors.push({
287289
orgSelector,
@@ -471,15 +473,32 @@ const scopeCssText = (
471473
cssText = scopeSelectors(cssText, scopeId, hostScopeId, slotScopeId, commentOriginalSelector);
472474
}
473475

474-
cssText = cssText.replace(/-shadowcsshost-no-combinator/g, `.${hostScopeId}`);
476+
cssText = replaceShadowCssHost(cssText, hostScopeId);
475477
cssText = cssText.replace(/>\s*\*\s+([^{, ]+)/gm, ' $1 ');
476478

477479
return {
478480
cssText: cssText.trim(),
479-
slottedSelectors: slotted.selectors,
481+
// We need to replace the shadow CSS host string in each of these selectors since we created
482+
// them prior to the replacement happening in the components CSS text.
483+
slottedSelectors: slotted.selectors.map((ref) => ({
484+
orgSelector: replaceShadowCssHost(ref.orgSelector, hostScopeId),
485+
updatedSelector: replaceShadowCssHost(ref.updatedSelector, hostScopeId),
486+
})),
480487
};
481488
};
482489

490+
/**
491+
* Helper function that replaces the interim string representing a `:host` selector with
492+
* the host scope selector class for the element.
493+
*
494+
* @param cssText The CSS string to make the replacement in
495+
* @param hostScopeId The scope ID that will be used as the class representing the host element
496+
* @returns CSS with the selector replaced
497+
*/
498+
const replaceShadowCssHost = (cssText: string, hostScopeId: string) => {
499+
return cssText.replace(/-shadowcsshost-no-combinator/g, `.${hostScopeId}`);
500+
};
501+
483502
export const scopeCss = (cssText: string, scopeId: string, commentOriginalSelector: boolean) => {
484503
const hostScopeId = scopeId + '-h';
485504
const slotScopeId = scopeId + '-s';
@@ -528,7 +547,8 @@ export const scopeCss = (cssText: string, scopeId: string, commentOriginalSelect
528547
}
529548

530549
scoped.slottedSelectors.forEach((slottedSelector) => {
531-
cssText = cssText.replace(slottedSelector.orgSelector, slottedSelector.updatedSelector);
550+
const regex = new RegExp(escapeRegExpSpecialCharacters(slottedSelector.orgSelector), 'g');
551+
cssText = cssText.replace(regex, slottedSelector.updatedSelector);
532552
});
533553

534554
return cssText;
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { escapeRegExpSpecialCharacters } from '../regular-expression';
2+
3+
describe('regular expression utils', () => {
4+
describe('escapeRegExpSpecialCharacters', () => {
5+
it('should escape all special characters', () => {
6+
const text = 'This is a string with special characters: $ ^ * + ? . ( ) | { } [ ]';
7+
const expected = 'This is a string with special characters: \\$ \\^ \\* \\+ \\? \\. \\( \\) \\| \\{ \\} \\[ \\]';
8+
const result = escapeRegExpSpecialCharacters(text);
9+
expect(result).toEqual(expected);
10+
});
11+
12+
it('should escape only special characters', () => {
13+
const text = 'This is a string without special characters';
14+
const expected = 'This is a string without special characters';
15+
const result = escapeRegExpSpecialCharacters(text);
16+
expect(result).toEqual(expected);
17+
});
18+
19+
it('should ignore an empty string', () => {
20+
const text = '';
21+
const expected = '';
22+
const result = escapeRegExpSpecialCharacters(text);
23+
expect(result).toEqual(expected);
24+
});
25+
});
26+
});

src/utils/test/scope-css.spec.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,15 @@ describe('ShadowCss', function () {
264264

265265
it('should handle :host complex selector', () => {
266266
const r = s(':host > ::slotted(*:nth-of-type(2n - 1)) {}', 'sc-ion-tag');
267-
expect(r).toEqual('.sc-ion-tag-h > .sc-ion-tag-s > *:nth-of-type(2n - 1) {}');
267+
expect(r).toEqual(
268+
'.sc-ion-tag-h >.sc-ion-tag-s > *:nth-of-type(2n - 1), .sc-ion-tag-h > .sc-ion-tag-s > *:nth-of-type(2n - 1) {}',
269+
);
268270
});
269271

270272
it('should handle host-context complex selector', () => {
271273
const r = s(':host-context(.red) > ::slotted(*:nth-of-type(2n - 1)) {}', 'sc-ion-tag');
272274
expect(r).toEqual(
273-
'.sc-ion-tag-h.red > .sc-ion-tag-s > *:nth-of-type(2n - 1), .red .sc-ion-tag-h > .sc-ion-tag-s > *:nth-of-type(2n - 1) {}',
275+
'.sc-ion-tag-h.red >.sc-ion-tag-s > *:nth-of-type(2n - 1), .sc-ion-tag-h.red > .sc-ion-tag-s > *:nth-of-type(2n - 1), .red .sc-ion-tag-h >.sc-ion-tag-s > *:nth-of-type(2n - 1), .red .sc-ion-tag-h > .sc-ion-tag-s > *:nth-of-type(2n - 1) {}',
274276
);
275277
});
276278

0 commit comments

Comments
 (0)