Skip to content

Commit

Permalink
fix(compiler): scope css keyframes in emulated view encapsulation
Browse files Browse the repository at this point in the history
Ensure that keyframes rules, defined within components with emulated
view encapsulation, are scoped to avoid collisions with keyframes in
other components.

This is achieved by renaming these keyframes to add a prefix that makes
them unique across the application.

In order to enabling the handling of keyframes names defined as strings
the previous strategy of replacing quoted css content with `%QUOTED%`
(introduced in commit 7f689a2) has been removed and in its place now
only specific characters inside quotes are being replaced with
placeholder text (those are `;`, `:` and `,`, more can be added in
the future if the need arises).

Closes angular#33885

BREAKING CHANGE:

Keyframes names are now prefixed with the component's "scope name".
For example, the following keyframes rule in a component definition,
whose "scope name" is host-my-cmp:

   @Keyframes foo { ... }

will become:

   @Keyframes host-my-cmp_foo { ... }

Any TypeScript/JavaScript code which relied on the names of keyframes rules
will no longer match.

The recommended solutions in this case are to either:
- change the component's view encapsulation to the `None` or `ShadowDom`
- define keyframes rules in global stylesheets (e.g styles.css)
- define keyframes rules programmatically in code.
  • Loading branch information
dario-piotrowicz committed Aug 18, 2021
1 parent 3e756f0 commit d7ee135
Show file tree
Hide file tree
Showing 3 changed files with 806 additions and 37 deletions.
334 changes: 308 additions & 26 deletions packages/compiler/src/shadow_css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,27 @@
*/

/**
* This file is a port of shadowCSS from webcomponents.js to TypeScript.
* The following set contains all keywords that can be used in the animation css shorthand
* property and is used during the scoping of keyframes to make sure such keywords
* are not modified.
*/
const animationKeywords = new Set([
// global values
'inherit', 'initial', 'revert', 'unset',
// animation-direction
'alternate', 'alternate-reverse', 'normal', 'reverse',
// animation-fill-mode
'backwards', 'both', 'forwards', 'none',
// animation-play-state
'paused', 'running',
// animation-timing-function
'ease', 'ease-in', 'ease-in-out', 'ease-out', 'linear', 'step-start', 'step-end',
// `steps()` function
'end', 'jump-both', 'jump-end', 'jump-none', 'jump-start', 'start'
]);

/**
* The following class is a port of shadowCSS from webcomponents.js to TypeScript.
*
* Please make sure to keep to edits in sync with the source file.
*
Expand Down Expand Up @@ -131,7 +151,6 @@
declaration. This is a directive to the styling shim to use the selector
in comments in lieu of the next selector when running under polyfill.
*/

export class ShadowCss {
strictStyling: boolean = true;

Expand All @@ -157,6 +176,201 @@ export class ShadowCss {
return this._insertPolyfillRulesInCssText(cssText);
}

/**
* Process styles to add scope to keyframes.
*
* Modify both the names of the keyframes defined in the component styles and also the css
* animation rules using them.
*
* Animation rules using keyframes defined are not modified to allow for globally defined
* keyframes.
*
* For example, we convert this css:
*
* ```
* .box {
* animation: box-animation 1s forwards;
* }
*
* @keyframes box-animation {
* to {
* background-color: green;
* }
* }
* ```
*
* to this:
*
* ```
* .box {
* animation: scopeName_box-animation 1s forwards;
* }
*
* @keyframes scopeName_box-animation {
* to {
* background-color: green;
* }
* }
* ```
*
* @param cssText the component's css text that needs to be scoped.
* @param scopeSelector the component's scope selector.
*
* @returns the scoped css text.
*/
private _scopeKeyframesRelatedCss(cssText: string, scopeSelector: string): string {
const {cssText: scopedKeyframesCssText, unscopedKeyframesSet} =
this._scopeLocalKeyframes(cssText, scopeSelector);

return processRules(
scopedKeyframesCssText,
rule => this._scopeAnimationRule(rule, scopeSelector, unscopedKeyframesSet));
}

/**
* Scopes local keyframes names, returning the updated css text and a set that is
* used to collect all keyframes names (before scoping) so that they can
* later be used to scope the animation rules.
*
* For example, it takes this css:
*
* ```
* @keyframes box-animation {
* to {
* background-color: green;
* }
* }
* ```
*
* and returns:
*
* ```
* cssText: @keyframes scopeName_box-animation {
* to {
* background-color: green;
* }
* }
*
* unscopedKeyframesSet: Set(1) {"box-animation"}
* ```
*
* @param cssText the component's css text.
* @param scopeSelector the component's scope selector.
*
* @returns object containing the updated css text and the set with the original/unscoped
* keyframes names.
*/
private _scopeLocalKeyframes(cssText: string, scopeSelector: string):
{cssText: string, unscopedKeyframesSet: Set<string>} {
const unscopedKeyframesSet = new Set<string>();

const scopedKeyframesCssText = processRules(
cssText,
rule => ({
...rule,
selector: rule.selector.replace(
/(^@(?:-webkit-)?keyframes(?:\s+))(['"]?)(.+)\2(\s*)$/,
(_, start, quote, keyframeName, endSpaces) => {
unscopedKeyframesSet.add(keyframeName);
return `${start}${quote}${scopeSelector}_${keyframeName}${quote}${endSpaces}`;
}),
}));

return {cssText: scopedKeyframesCssText, unscopedKeyframesSet};
}

/**
* Utility function used to determine if a provided keyframes name needs to be
* scoped or not, depending on a provided set of unscopedKeyframes names.
*
* The function checks the name against a set of local unscoped keyframes names (if the
* name is in the set then it needs to be scoped).
*
* It also handles different quote escaping.
*
* For example a name such as "foo ' bar" can be matched with "foo ' bar" but also
* with 'foo \' bar', the function takes this into consideration.
*
* @param keyframe the keyframes name to check.
* @param unscopedKeyframesSet the set of unscoped keyframes names.
*
* @returns boolean indicating whether the provided keyframes name needs to be scoped or not.
*/
private _keyframeNeedsToBeScoped(keyframe: string, unscopedKeyframesSet: Set<string>): boolean {
return unscopedKeyframesSet.has(keyframe) ||
unscopedKeyframesSet.has(keyframe.replace(/\\'/g, '\'')) ||
unscopedKeyframesSet.has(keyframe.replace(/\\"/g, '"')) ||
unscopedKeyframesSet.has(keyframe.replace(/(?<!\\)'/g, '\\\'')) ||
unscopedKeyframesSet.has(keyframe.replace(/(?<!\\)"/g, '\\"'));
}

/**
* Function used to scope a keyframes name (obtained from an animation declaration)
* using an existing set of unscopedKeyframes names to discern if the scoping needs to be
* performed (keyframes names of keyframes not defined in the component's css need not to be
* scoped).
*
* @param keyframe the keyframes name to check.
* @param scopeSelector the component's scope selector.
* @param unscopedKeyframesSet the set of unscoped keyframes names.
*
* @returns the scoped name of the keyframe, or the original name is the name need not to be
* scoped.
*/
private _scopeAnimationKeyframe(
keyframe: string, scopeSelector: string, unscopedKeyframesSet: Set<string>): string {
return keyframe.replace(/^(\s*)(['"]?)(.+?)\2(\s*)$/, (_, spaces1, quote, name, spaces2) => {
name =
`${this._keyframeNeedsToBeScoped(name, unscopedKeyframesSet) ? scopeSelector + '_' : ''}${
name}`;
return `${spaces1}${quote}${name}${quote}${spaces2}`;
});
}

/**
* Scope an animation rule so that the keyframes mentioned in such rule
* are scoped if defined in the component's css and left untouched otherwise.
*
* It can scope values of both the 'animation' and 'animation-name' properties.
*
* @param rule css rule to scope.
* @param scopeSelector the component's scope selector.
* @param unscopedKeyframesSet the set of unscoped keyframes names.
*
* @returns the updated css rule.
**/
private _scopeAnimationRule(
rule: CssRule, scopeSelector: string, unscopedKeyframesSet: Set<string>): CssRule {
let content = rule.content.replace(
/((?:^|\s+)(?:-webkit-)?animation(?:\s*):(?:\s*))([^;]+)/g,
(_, start, animationDeclarations) => start +
animationDeclarations.replace(
/(^|\s+)(?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*))(?=[,\s]|;|$)/g,
(original: string, leadingSpaces: string, quote = '', quotedName: string,
nonQuotedName: string) => {
if (quotedName) {
return `${leadingSpaces}${
this._scopeAnimationKeyframe(
`${quote}${quotedName}${quote}`, scopeSelector, unscopedKeyframesSet)}`;
} else {
return animationKeywords.has(nonQuotedName) ?
original :
`${leadingSpaces}${
this._scopeAnimationKeyframe(
nonQuotedName, scopeSelector, unscopedKeyframesSet)}`;
}
}));
content = content.replace(
/((?:^|\s+)(?:-webkit-)?animation-name(?:\s*):(?:\s*))([^;]+)/g,
(_match, start, csvKeyframes) => `${start}${
csvKeyframes.split(',')
.map(
(keyframe: string) =>
this._scopeAnimationKeyframe(keyframe, scopeSelector, unscopedKeyframesSet))
.join(',')}`);
return {...rule, content};
}

/*
* Process styles to convert native ShadowDOM rules that will trip
* up the css parser; we rely on decorating the stylesheet with inert rules.
Expand Down Expand Up @@ -217,6 +431,7 @@ export class ShadowCss {
cssText = this._convertColonHostContext(cssText);
cssText = this._convertShadowDOMSelectors(cssText);
if (scopeSelector) {
cssText = this._scopeKeyframesRelatedCss(cssText, scopeSelector);
cssText = this._scopeSelectors(cssText, scopeSelector, hostSelector);
}
cssText = cssText + '\n' + unscopedRules;
Expand Down Expand Up @@ -642,39 +857,39 @@ function extractCommentsWithHash(input: string): string[] {
}

const BLOCK_PLACEHOLDER = '%BLOCK%';
const QUOTE_PLACEHOLDER = '%QUOTED%';
const _ruleRe = /(\s*)([^;\{\}]+?)(\s*)((?:{%BLOCK%}?\s*;?)|(?:\s*;))/g;
const _quotedRe = /%QUOTED%/g;
const CONTENT_PAIRS = new Map([['{', '}']]);
const QUOTE_PAIRS = new Map([[`"`, `"`], [`'`, `'`]]);

const COMMA_IN_PLACEHOLDER = '%COMMA_IN_PLACEHOLDER%';
const SEMI_IN_PLACEHOLDER = '%SEMI_IN_PLACEHOLDER%';
const COLON_IN_PLACEHOLDER = '%COLON_IN_PLACEHOLDER%';

const _cssCommaInPlaceholderReGlobal = new RegExp(COMMA_IN_PLACEHOLDER, 'g');
const _cssSemiInPlaceholderReGlobal = new RegExp(SEMI_IN_PLACEHOLDER, 'g');
const _cssColonInPlaceholderReGlobal = new RegExp(COLON_IN_PLACEHOLDER, 'g');

export class CssRule {
constructor(public selector: string, public content: string) {}
}

export function processRules(input: string, ruleCallback: (rule: CssRule) => CssRule): string {
const inputWithEscapedQuotes = escapeBlocks(input, QUOTE_PAIRS, QUOTE_PLACEHOLDER);
const inputWithEscapedBlocks =
escapeBlocks(inputWithEscapedQuotes.escapedString, CONTENT_PAIRS, BLOCK_PLACEHOLDER);
input = escapeInStrings(input);
const inputWithEscapedBlocks = escapeBlocks(input, CONTENT_PAIRS, BLOCK_PLACEHOLDER);
let nextBlockIndex = 0;
let nextQuoteIndex = 0;
return inputWithEscapedBlocks.escapedString
.replace(
_ruleRe,
(...m: string[]) => {
const selector = m[2];
let content = '';
let suffix = m[4];
let contentPrefix = '';
if (suffix && suffix.startsWith('{' + BLOCK_PLACEHOLDER)) {
content = inputWithEscapedBlocks.blocks[nextBlockIndex++];
suffix = suffix.substring(BLOCK_PLACEHOLDER.length + 1);
contentPrefix = '{';
}
const rule = ruleCallback(new CssRule(selector, content));
return `${m[1]}${rule.selector}${m[3]}${contentPrefix}${rule.content}${suffix}`;
})
.replace(_quotedRe, () => inputWithEscapedQuotes.blocks[nextQuoteIndex++]);
const tmpResult = inputWithEscapedBlocks.escapedString.replace(_ruleRe, (...m: string[]) => {
const selector = m[2];
let content = '';
let suffix = m[4];
let contentPrefix = '';
if (suffix && suffix.startsWith('{' + BLOCK_PLACEHOLDER)) {
content = inputWithEscapedBlocks.blocks[nextBlockIndex++];
suffix = suffix.substring(BLOCK_PLACEHOLDER.length + 1);
contentPrefix = '{';
}
const rule = ruleCallback(new CssRule(selector, content));
return `${m[1]}${rule.selector}${m[3]}${contentPrefix}${rule.content}${suffix}`;
});
return unescapeInStrings(tmpResult);
}

class StringWithEscapedBlocks {
Expand Down Expand Up @@ -722,6 +937,73 @@ function escapeBlocks(
return new StringWithEscapedBlocks(resultParts.join(''), escapedBlocks);
}

/**
* Object containing as keys characters that should be substituted by placeholders
* when found in strings during the css text parsing, and as values the respective
* placeholders
*/
const ESCAPE_IN_STRING_MAP: {[key: string]: string} = {
';': SEMI_IN_PLACEHOLDER,
',': COMMA_IN_PLACEHOLDER,
':': COLON_IN_PLACEHOLDER
};

/**
* Parse the provided css text and inside strings (meaning, inside pairs of unescaped single or
* double quotes) replace specific characters with their respective placeholders as indicated
* by the `ESCAPE_IN_STRING_MAP` map.
*
* For example convert the text
* `animation: "my-anim:at\"ion" 1s;`
* to
* `animation: "my-anim%COLON_IN_PLACEHOLDER%at\"ion" 1s;`
*
* This is necessary in order to the remove the meaning of some characters when found inside strings
* (for example `;` indicates the end of a css declaration, `,` the sequence of values and `:` the
* division between property and value during a declaration, none of these meanings apply when such
* characters are within strings and so in order to prevent parsing issues they need to be replaced
* with placeholder text for the duration of the css manipulation process).
*
* @param input the original css text.
*
* @returns the css text with specific characters in strings replaced by placeholders.
**/
function escapeInStrings(input: string): string {
let result = input;
let insideString = false;
let currentQuoteChar: string|undefined;
for (let i = 0; i < result.length; i++) {
const char = result[i];
if (char === '\\') {
i++;
} else {
if (insideString) {
if (char === currentQuoteChar) {
currentQuoteChar = undefined;
insideString = false;
} else {
const placeholder: string|undefined = ESCAPE_IN_STRING_MAP[char];
if (placeholder) {
result = `${result.substr(0, i)}${placeholder}${result.substr(i + 1)}`;
i += placeholder.length - 1;
}
}
} else if (char === '\'' || char === '"') {
currentQuoteChar = char;
insideString = true;
}
}
}
return result;
}

function unescapeInStrings(input: string): string {
let result = input.replace(_cssCommaInPlaceholderReGlobal, ',');
result = result.replace(_cssSemiInPlaceholderReGlobal, ';');
result = result.replace(_cssColonInPlaceholderReGlobal, ':');
return result;
}

/**
* Combine the `contextSelectors` with the `hostMarker` and the `otherSelectors`
* to create a selector that matches the same as `:host-context()`.
Expand Down
Loading

0 comments on commit d7ee135

Please sign in to comment.