From e8f8dc2158009d6a28608eff7c10305d283e1f9f Mon Sep 17 00:00:00 2001 From: dario-piotrowicz Date: Sun, 20 Jun 2021 12:03:48 +0100 Subject: [PATCH 1/2] fix(compiler): scope css keyframes in emulated view encapsulation 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 enable 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 #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. --- packages/compiler/src/shadow_css.ts | 360 ++++++++++++- .../test/shadow_css/keyframes_spec.ts | 510 ++++++++++++++++++ .../test/{ => shadow_css}/shadow_css_spec.ts | 11 - 3 files changed, 844 insertions(+), 37 deletions(-) create mode 100644 packages/compiler/test/shadow_css/keyframes_spec.ts rename packages/compiler/test/{ => shadow_css}/shadow_css_spec.ts (98%) diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 40acc43fc1a49..3604f64d90fa0 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -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. * @@ -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; @@ -157,6 +176,187 @@ 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 elsewhere 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 unscopedKeyframesSet = new Set(); + const scopedKeyframesCssText = processRules( + cssText, + rule => this._scopeLocalKeyframeDeclarations(rule, scopeSelector, unscopedKeyframesSet)); + return processRules( + scopedKeyframesCssText, + rule => this._scopeAnimationRule(rule, scopeSelector, unscopedKeyframesSet)); + } + + /** + * Scopes local keyframes names, returning the updated css rule and it also + * adds the original keyframe name to a provided set to collect all keyframes names + * so that it can later be used to scope the animation rules. + * + * For example, it takes a rule such as: + * + * ``` + * @keyframes box-animation { + * to { + * background-color: green; + * } + * } + * ``` + * + * and returns: + * + * ``` + * @keyframes scopeName_box-animation { + * to { + * background-color: green; + * } + * } + * ``` + * and as a side effect it adds "box-animation" to the `unscopedKeyframesSet` set + * + * @param cssRule the css rule to process. + * @param scopeSelector the component's scope selector. + * @param unscopedKeyframesSet the set of unscoped keyframes names (which can be + * modified as a side effect) + * + * @returns the css rule modified with the scoped keyframes name. + */ + private _scopeLocalKeyframeDeclarations( + rule: CssRule, scopeSelector: string, unscopedKeyframesSet: Set): CssRule { + return { + ...rule, + selector: rule.selector.replace( + /(^@(?:-webkit-)?keyframes(?:\s+))(['"]?)(.+)\2(\s*)$/, + (_, start, quote, keyframeName, endSpaces) => { + unscopedKeyframesSet.add(unescapeQuotes(keyframeName, quote)); + return `${start}${quote}${scopeSelector}_${keyframeName}${quote}${endSpaces}`; + }), + }; + } + + /** + * 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: ReadonlySet): string { + return keyframe.replace(/^(\s*)(['"]?)(.+?)\2(\s*)$/, (_, spaces1, quote, name, spaces2) => { + name = `${unscopedKeyframesSet.has(unescapeQuotes(name, quote)) ? scopeSelector + '_' : ''}${ + name}`; + return `${spaces1}${quote}${name}${quote}${spaces2}`; + }); + } + + /** + * Regular expression used to extrapolate the possible keyframes from an + * animation declaration (with possibly multiple animation definitions) + * + * The regular expression can be divided in three parts + * - (^|\s+) + * simply captures how many (if any) leading whitespaces are present + * - (?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*)) + * captures two different possible keyframes, ones which are quoted or ones which are valid css + * idents (custom properties excluded) + * - (?=[,\s;]|$) + * simply matches the end of the possible keyframe, valid endings are: a comma, a space, a + * semicolon or the end of the string + */ + private _animationDeclarationKeyframesRe = + /(^|\s+)(?:(?:(['"])((?:\\\\|\\\2|(?!\2).)+)\2)|(-?[A-Za-z][\w\-]*))(?=[,\s]|$)/g; + + /** + * 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: ReadonlySet): CssRule { + let content = rule.content.replace( + /((?:^|\s+)(?:-webkit-)?animation(?:\s*):(?:\s*))([^;]+)/g, + (_, start, animationDeclarations) => start + + animationDeclarations.replace( + this._animationDeclarationKeyframesRe, + (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, commaSeparatedKeyframes) => `${start}${ + commaSeparatedKeyframes.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. @@ -217,6 +417,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; @@ -642,39 +843,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); + const escaped = escapeInStrings(input); + const inputWithEscapedBlocks = escapeBlocks(escaped, 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 escapedResult = 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(escapedResult); } class StringWithEscapedBlocks { @@ -722,6 +923,113 @@ 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 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 currentQuoteChar: string|null = null; + for (let i = 0; i < result.length; i++) { + const char = result[i]; + if (char === '\\') { + i++; + } else { + if (currentQuoteChar !== null) { + // index i is inside a quoted sub-string + if (char === currentQuoteChar) { + currentQuoteChar = null; + } 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; + } + } + } + return result; +} + +/** + * Replace in a string all occurrences of keys in the `ESCAPE_IN_STRING_MAP` map with their + * original representation, this is simply used to revert the changes applied by the + * escapeInStrings function. + * + * For example it reverts the text: + * `animation: "my-anim%COLON_IN_PLACEHOLDER%at\"ion" 1s;` + * to it's original form of: + * `animation: "my-anim:at\"ion" 1s;` + * + * Note: For the sake of simplicity this function does not check that the placeholders are + * actually inside strings as it would anyway be extremely unlikely to find them outside of strings. + * + * @param input the css text containing the placeholders. + * + * @returns the css text without the placeholders. + */ +function unescapeInStrings(input: string): string { + let result = input.replace(_cssCommaInPlaceholderReGlobal, ','); + result = result.replace(_cssSemiInPlaceholderReGlobal, ';'); + result = result.replace(_cssColonInPlaceholderReGlobal, ':'); + return result; +} + +/** + * Unescape all quotes present in a string, but only if the string was actually already + * quoted. + * + * This generates a "canonical" representation of strings which can be used to match strings + * which would otherwise only differ because of differently escaped quotes. + * + * For example it converts the string (assumed to be quoted): + * `this \\"is\\" a \\'\\\\'test` + * to: + * `this "is" a '\\\\'test` + * (note that the latter backslashes are not removed as they are not actually escaping the single + * quote) + * + * + * @param input the string possibly containing escaped quotes. + * @param isQuoted boolean indicating whether the string was quoted inside a bigger string (if not + * then it means that it doesn't represent an inner string and thus no unescaping is required) + * + * @returns the string in the "canonical" representation without escaped quotes. + */ +function unescapeQuotes(str: string, isQuoted: boolean): string { + return !isQuoted ? str : str.replace(/((?:^|[^\\])(?:\\\\)*)\\(?=['"])/g, '$1'); +} + /** * Combine the `contextSelectors` with the `hostMarker` and the `otherSelectors` * to create a selector that matches the same as `:host-context()`. diff --git a/packages/compiler/test/shadow_css/keyframes_spec.ts b/packages/compiler/test/shadow_css/keyframes_spec.ts new file mode 100644 index 0000000000000..7e2ccfa3c9360 --- /dev/null +++ b/packages/compiler/test/shadow_css/keyframes_spec.ts @@ -0,0 +1,510 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ShadowCss} from '@angular/compiler/src/shadow_css'; + +describe('ShadowCss, keyframes and animations', () => { + function s(css: string, contentAttr: string, hostAttr: string = '') { + const shadowCss = new ShadowCss(); + return shadowCss.shimCssText(css, contentAttr, hostAttr); + } + + it('should scope keyframes rules', () => { + const css = '@keyframes foo {0% {transform:translate(-50%) scaleX(0);}}'; + const expected = '@keyframes host-a_foo {0% {transform:translate(-50%) scaleX(0);}}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should scope -webkit-keyframes rules', () => { + const css = '@-webkit-keyframes foo {0% {-webkit-transform:translate(-50%) scaleX(0);}} '; + const expected = + '@-webkit-keyframes host-a_foo {0% {-webkit-transform:translate(-50%) scaleX(0);}}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should scope animations using local keyframes identifiers', () => { + const css = ` + button { + animation: foo 10s ease; + } + @keyframes foo { + 0% { + transform: translate(-50%) scaleX(0); + } + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation: host-a_foo 10s ease;'); + }); + + it('should not scope animations using non-local keyframes identifiers', () => { + const css = ` + button { + animation: foo 10s ease; + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation: foo 10s ease;'); + }); + + it('should scope animation-names using local keyframes identifiers', () => { + const css = ` + button { + animation-name: foo; + } + @keyframes foo { + 0% { + transform: translate(-50%) scaleX(0); + } + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation-name: host-a_foo;'); + }); + + it('should not scope animation-names using non-local keyframes identifiers', () => { + const css = ` + button { + animation-name: foo; + } + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation-name: foo;'); + }); + + it('should handle (scope or not) multiple animation-names', () => { + const css = ` + button { + animation-name: foo, bar,baz, qux , quux ,corge ,grault ,garply, waldo; + } + @keyframes foo {} + @keyframes baz {} + @keyframes quux {} + @keyframes grault {} + @keyframes waldo {}`; + const result = s(css, 'host-a'); + const animationNames = [ + 'host-a_foo', + ' bar', + 'host-a_baz', + ' qux ', + ' host-a_quux ', + 'corge ', + 'host-a_grault ', + 'garply', + ' host-a_waldo', + ]; + const expected = `animation-name: ${animationNames.join(',')};`; + expect(result).toContain(expected); + }); + + it('should handle (scope or not) multiple animation-names defined over multiple lines', () => { + const css = ` + button { + animation-name: foo, + bar,baz, + qux , + quux , + grault, + garply, waldo; + } + @keyframes foo {} + @keyframes baz {} + @keyframes quux {} + @keyframes grault {}`; + const result = s(css, 'host-a'); + ['foo', 'baz', 'quux', 'grault'].forEach( + scoped => expect(result).toContain(`host-a_${scoped}`)); + ['bar', 'qux', 'garply', 'waldo'].forEach(nonScoped => { + expect(result).toContain(nonScoped); + expect(result).not.toContain(`host-a_${nonScoped}`); + }); + }); + + it('should handle (scope or not) multiple animation definitions in a single declaration', () => { + const css = ` + div { + animation: 1s ease foo, 2s bar infinite, forwards baz 3s; + } + + p { + animation: 1s "foo", 2s "bar"; + } + + span { + animation: .5s ease 'quux', + 1s foo infinite, forwards "baz'" 1.5s, + 2s bar; + } + + button { + animation: .5s bar, + 1s foo 0.3s, 2s quux; + } + + @keyframes bar {} + @keyframes quux {} + @keyframes "baz'" {}`; + const result = s(css, 'host-a'); + expect(result).toContain('animation: 1s ease foo, 2s host-a_bar infinite, forwards baz 3s;'); + expect(result).toContain('animation: 1s "foo", 2s "host-a_bar";'); + expect(result).toContain(` + animation: .5s host-a_bar, + 1s foo 0.3s, 2s host-a_quux;`); + expect(result).toContain(` + animation: .5s ease 'host-a_quux', + 1s foo infinite, forwards "host-a_baz'" 1.5s, + 2s host-a_bar;`); + }); + + it(`should not modify css variables ending with 'animation' even if they reference a local keyframes identifier`, + () => { + const css = ` + button { + --variable-animation: foo; + } + @keyframes foo {}`; + const result = s(css, 'host-a'); + expect(result).toContain('--variable-animation: foo;'); + }); + + it(`should not modify css variables ending with 'animation-name' even if they reference a local keyframes identifier`, + () => { + const css = ` + button { + --variable-animation-name: foo; + } + @keyframes foo {}`; + const result = s(css, 'host-a'); + expect(result).toContain('--variable-animation-name: foo;'); + }); + + it('should maintain the spacing when handling (scoping or not) keyframes and animations', () => { + const css = ` + div { + animation-name : foo; + animation: 5s bar 1s backwards; + animation : 3s baz ; + animation-name:foobar ; + animation:1s "foo" , 2s "bar",3s "quux"; + } + + @-webkit-keyframes bar {} + @keyframes foobar {} + @keyframes quux {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation-name : foo;'); + expect(result).toContain('animation: 5s host-a_bar 1s backwards;'); + expect(result).toContain('animation : 3s baz ;'); + expect(result).toContain('animation-name:host-a_foobar ;'); + expect(result).toContain('@-webkit-keyframes host-a_bar {}'); + expect(result).toContain('@keyframes host-a_foobar {}'); + expect(result).toContain('animation:1s "foo" , 2s "host-a_bar",3s "host-a_quux"'); + }); + + it('should ignore keywords values when scoping local animations', () => { + const css = ` + div { + animation: inherit; + animation: unset; + animation: 3s ease reverse foo; + animation: 5s foo 1s backwards; + animation: none 1s foo; + animation: .5s foo paused; + animation: 1s running foo; + animation: 3s linear 1s infinite running foo; + animation: 5s foo ease; + animation: 3s .5s infinite steps(3,end) foo; + animation: 5s steps(9, jump-start) jump .5s; + animation: 1s step-end steps; + } + + @keyframes foo {} + @keyframes inherit {} + @keyframes unset {} + @keyframes ease {} + @keyframes reverse {} + @keyframes backwards {} + @keyframes none {} + @keyframes paused {} + @keyframes linear {} + @keyframes running {} + @keyframes end {} + @keyframes jump {} + @keyframes start {} + @keyframes steps {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('animation: inherit;'); + expect(result).toContain('animation: unset;'); + expect(result).toContain('animation: 3s ease reverse host-a_foo;'); + expect(result).toContain('animation: 5s host-a_foo 1s backwards;'); + expect(result).toContain('animation: none 1s host-a_foo;'); + expect(result).toContain('animation: .5s host-a_foo paused;'); + expect(result).toContain('animation: 1s running host-a_foo;'); + expect(result).toContain('animation: 3s linear 1s infinite running host-a_foo;'); + expect(result).toContain('animation: 5s host-a_foo ease;'); + expect(result).toContain('animation: 3s .5s infinite steps(3,end) host-a_foo;'); + expect(result).toContain('animation: 5s steps(9, jump-start) host-a_jump .5s;'); + expect(result).toContain('animation: 1s step-end host-a_steps;'); + }); + + it('should handle the usage of quotes', () => { + const css = ` + div { + animation: 1.5s foo; + } + + p { + animation: 1s 'foz bar'; + } + + @keyframes 'foo' {} + @keyframes "foz bar" {} + @keyframes bar {} + @keyframes baz {} + @keyframes qux {} + @keyframes quux {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes \'host-a_foo\' {}'); + expect(result).toContain('@keyframes "host-a_foz bar" {}'); + expect(result).toContain('animation: 1.5s host-a_foo;'); + expect(result).toContain('animation: 1s \'host-a_foz bar\';'); + }); + + it('should handle the usage of quotes containing escaped quotes', () => { + const css = ` + div { + animation: 1.5s "foo\\"bar"; + } + + p { + animation: 1s 'bar\\' \\'baz'; + } + + button { + animation-name: 'foz " baz'; + } + + @keyframes "foo\\"bar" {} + @keyframes "bar' 'baz" {} + @keyframes "foz \\" baz" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes "host-a_foo\\"bar" {}'); + expect(result).toContain(`@keyframes "host-a_bar' 'baz" {}`); + expect(result).toContain('@keyframes "host-a_foz \\" baz" {}'); + expect(result).toContain('animation: 1.5s "host-a_foo\\"bar";'); + expect(result).toContain('animation: 1s \'host-a_bar\\\' \\\'baz\';'); + expect(result).toContain(`animation-name: 'host-a_foz " baz';`); + }); + + it('should handle the usage of commas in multiple animation definitions in a single declaration', + () => { + const css = ` + button { + animation: 1s "foo bar, baz", 2s 'qux quux'; + } + + div { + animation: 500ms foo, 1s 'bar, baz', 1500ms bar; + } + + p { + animation: 3s "bar, baz", 3s 'foo, bar' 1s, 3s "qux quux"; + } + + @keyframes "qux quux" {} + @keyframes "bar, baz" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes "host-a_qux quux" {}'); + expect(result).toContain('@keyframes "host-a_bar, baz" {}'); + expect(result).toContain(`animation: 1s "foo bar, baz", 2s 'host-a_qux quux';`); + expect(result).toContain('animation: 500ms foo, 1s \'host-a_bar, baz\', 1500ms bar;'); + expect(result).toContain( + `animation: 3s "host-a_bar, baz", 3s 'foo, bar' 1s, 3s "host-a_qux quux";`); + }); + + it('should handle the usage of double quotes escaping in multiple animation definitions in a single declaration', + () => { + const css = ` + div { + animation: 1s "foo", 1.5s "bar"; + animation: 2s "fo\\"o", 2.5s "bar"; + animation: 3s "foo\\"", 3.5s "bar", 3.7s "ba\\"r"; + animation: 4s "foo\\\\", 4.5s "bar", 4.7s "baz\\""; + animation: 5s "fo\\\\\\"o", 5.5s "bar", 5.7s "baz\\""; + } + + @keyframes "foo" {} + @keyframes "fo\\"o" {} + @keyframes 'foo"' {} + @keyframes 'foo\\\\' {} + @keyframes bar {} + @keyframes "ba\\"r" {} + @keyframes "fo\\\\\\"o" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes "host-a_foo" {}'); + expect(result).toContain('@keyframes "host-a_fo\\"o" {}'); + expect(result).toContain(`@keyframes 'host-a_foo"' {}`); + expect(result).toContain('@keyframes \'host-a_foo\\\\\' {}'); + expect(result).toContain('@keyframes host-a_bar {}'); + expect(result).toContain('@keyframes "host-a_ba\\"r" {}'); + expect(result).toContain('@keyframes "host-a_fo\\\\\\"o"'); + expect(result).toContain('animation: 1s "host-a_foo", 1.5s "host-a_bar";'); + expect(result).toContain('animation: 2s "host-a_fo\\"o", 2.5s "host-a_bar";'); + expect(result).toContain( + 'animation: 3s "host-a_foo\\"", 3.5s "host-a_bar", 3.7s "host-a_ba\\"r";'); + expect(result).toContain( + 'animation: 4s "host-a_foo\\\\", 4.5s "host-a_bar", 4.7s "baz\\"";'); + expect(result).toContain( + 'animation: 5s "host-a_fo\\\\\\"o", 5.5s "host-a_bar", 5.7s "baz\\"";'); + }); + + it('should handle the usage of single quotes escaping in multiple animation definitions in a single declaration', + () => { + const css = ` + div { + animation: 1s 'foo', 1.5s 'bar'; + animation: 2s 'fo\\'o', 2.5s 'bar'; + animation: 3s 'foo\\'', 3.5s 'bar', 3.7s 'ba\\'r'; + animation: 4s 'foo\\\\', 4.5s 'bar', 4.7s 'baz\\''; + animation: 5s 'fo\\\\\\'o', 5.5s 'bar', 5.7s 'baz\\''; + } + + @keyframes foo {} + @keyframes 'fo\\'o' {} + @keyframes 'foo'' {} + @keyframes 'foo\\\\' {} + @keyframes "bar" {} + @keyframes 'ba\\'r' {} + @keyframes "fo\\\\\\'o" {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes host-a_foo {}'); + expect(result).toContain('@keyframes \'host-a_fo\\\'o\' {}'); + expect(result).toContain('@keyframes \'host-a_foo\'\' {}'); + expect(result).toContain('@keyframes \'host-a_foo\\\\\' {}'); + expect(result).toContain('@keyframes "host-a_bar" {}'); + expect(result).toContain('@keyframes \'host-a_ba\\\'r\' {}'); + expect(result).toContain(`@keyframes "host-a_fo\\\\\\'o" {}`); + expect(result).toContain('animation: 1s \'host-a_foo\', 1.5s \'host-a_bar\';'); + expect(result).toContain('animation: 2s \'host-a_fo\\\'o\', 2.5s \'host-a_bar\';'); + expect(result).toContain( + 'animation: 3s \'host-a_foo\\\'\', 3.5s \'host-a_bar\', 3.7s \'host-a_ba\\\'r\';'); + expect(result).toContain( + 'animation: 4s \'host-a_foo\\\\\', 4.5s \'host-a_bar\', 4.7s \'baz\\\'\';'); + expect(result).toContain( + 'animation: 5s \'host-a_fo\\\\\\\'o\', 5.5s \'host-a_bar\', 5.7s \'baz\\\'\''); + }); + + it('should handle the usage of mixed single and double quotes escaping in multiple animation definitions in a single declaration', + () => { + const css = ` + div { + animation: 1s 'f\\"oo', 1.5s "ba\\'r"; + animation: 2s "fo\\"\\"o", 2.5s 'b\\\\"ar'; + animation: 3s 'foo\\\\', 3.5s "b\\\\\\"ar", 3.7s 'ba\\'\\"\\'r'; + animation: 4s 'fo\\'o', 4.5s 'b\\"ar\\"', 4.7s "baz\\'"; + } + + @keyframes 'f"oo' {} + @keyframes 'fo""o' {} + @keyframes 'foo\\\\' {} + @keyframes 'fo\\'o' {} + @keyframes 'ba\\'r' {} + @keyframes 'b\\\\"ar' {} + @keyframes 'b\\\\\\"ar' {} + @keyframes 'b"ar"' {} + @keyframes 'ba\\'\\"\\'r' {} + `; + const result = s(css, 'host-a'); + expect(result).toContain(`@keyframes 'host-a_f"oo' {}`); + expect(result).toContain(`@keyframes 'host-a_fo""o' {}`); + expect(result).toContain('@keyframes \'host-a_foo\\\\\' {}'); + expect(result).toContain('@keyframes \'host-a_fo\\\'o\' {}'); + expect(result).toContain('@keyframes \'host-a_ba\\\'r\' {}'); + expect(result).toContain(`@keyframes 'host-a_b\\\\"ar' {}`); + expect(result).toContain(`@keyframes 'host-a_b\\\\\\"ar' {}`); + expect(result).toContain(`@keyframes 'host-a_b"ar"' {}`); + expect(result).toContain(`@keyframes 'host-a_ba\\'\\"\\'r' {}`); + expect(result).toContain(`animation: 1s 'host-a_f\\"oo', 1.5s "host-a_ba\\'r";`); + expect(result).toContain(`animation: 2s "host-a_fo\\"\\"o", 2.5s 'host-a_b\\\\"ar';`); + expect(result).toContain( + `animation: 3s 'host-a_foo\\\\', 3.5s "host-a_b\\\\\\"ar", 3.7s 'host-a_ba\\'\\"\\'r';`); + expect(result).toContain( + `animation: 4s 'host-a_fo\\'o', 4.5s 'host-a_b\\"ar\\"', 4.7s "baz\\'";`); + }); + + it('should handle the usage of commas inside quotes', () => { + const css = ` + div { + animation: 3s 'bar,, baz'; + } + + p { + animation-name: "bar,, baz", foo,'ease, linear , inherit', bar; + } + + @keyframes 'foo' {} + @keyframes 'bar,, baz' {} + @keyframes 'ease, linear , inherit' {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes \'host-a_bar,, baz\' {}'); + expect(result).toContain('animation: 3s \'host-a_bar,, baz\';'); + expect(result).toContain( + `animation-name: "host-a_bar,, baz", host-a_foo,'host-a_ease, linear , inherit', bar;`); + }); + + it('should not ignore animation keywords when they are inside quotes', () => { + const css = ` + div { + animation: 3s 'unset'; + } + + button { + animation: 5s "forwards" 1s forwards; + } + + @keyframes unset {} + @keyframes forwards {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes host-a_unset {}'); + expect(result).toContain('@keyframes host-a_forwards {}'); + expect(result).toContain('animation: 3s \'host-a_unset\';'); + expect(result).toContain('animation: 5s "host-a_forwards" 1s forwards;'); + }); + + it('should handle css functions correctly', () => { + const css = ` + div { + animation: foo 0.5s alternate infinite cubic-bezier(.17, .67, .83, .67); + } + + button { + animation: calc(2s / 2) calc; + } + + @keyframes foo {} + @keyframes cubic-bezier {} + @keyframes calc {} + `; + const result = s(css, 'host-a'); + expect(result).toContain('@keyframes host-a_cubic-bezier {}'); + expect(result).toContain('@keyframes host-a_calc {}'); + expect(result).toContain( + 'animation: host-a_foo 0.5s alternate infinite cubic-bezier(.17, .67, .83, .67);'); + expect(result).toContain('animation: calc(2s / 2) host-a_calc;'); + }); +}); diff --git a/packages/compiler/test/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts similarity index 98% rename from packages/compiler/test/shadow_css_spec.ts rename to packages/compiler/test/shadow_css/shadow_css_spec.ts index 90f50d876e39b..edc14bcb42a6d 100644 --- a/packages/compiler/test/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -126,17 +126,6 @@ import {normalizeCSS} from '@angular/platform-browser/testing/src/browser_util'; expect(s(css, 'contenta')).toEqual(expected); }); - // Check that the browser supports unprefixed CSS animation - it('should handle keyframes rules', () => { - const css = '@keyframes foo {0% {transform:translate(-50%) scaleX(0);}}'; - expect(s(css, 'contenta')).toEqual(css); - }); - - it('should handle -webkit-keyframes rules', () => { - const css = '@-webkit-keyframes foo {0% {-webkit-transform:translate(-50%) scaleX(0);}}'; - expect(s(css, 'contenta')).toEqual(css); - }); - it('should handle complicated selectors', () => { expect(s('one::before {}', 'contenta')).toEqual('one[contenta]::before {}'); expect(s('one two {}', 'contenta')).toEqual('one[contenta] two[contenta] {}'); From 5a6f6c9c219567c1ece43bacd046ea96dcde4c25 Mon Sep 17 00:00:00 2001 From: dario-piotrowicz Date: Sun, 25 Sep 2022 23:17:41 +0100 Subject: [PATCH 2/2] fixup! fix(compiler): scope css keyframes in emulated view encapsulation --- packages/compiler/src/shadow_css.ts | 4 ++-- .../test/shadow_css/keyframes_spec.ts | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 3604f64d90fa0..eab565fb6a875 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -328,7 +328,7 @@ export class ShadowCss { private _scopeAnimationRule( rule: CssRule, scopeSelector: string, unscopedKeyframesSet: ReadonlySet): CssRule { let content = rule.content.replace( - /((?:^|\s+)(?:-webkit-)?animation(?:\s*):(?:\s*))([^;]+)/g, + /((?:^|\s+|;)(?:-webkit-)?animation(?:\s*):(?:\s*))([^;]+)/g, (_, start, animationDeclarations) => start + animationDeclarations.replace( this._animationDeclarationKeyframesRe, @@ -347,7 +347,7 @@ export class ShadowCss { } })); content = content.replace( - /((?:^|\s+)(?:-webkit-)?animation-name(?:\s*):(?:\s*))([^;]+)/g, + /((?:^|\s+|;)(?:-webkit-)?animation-name(?:\s*):(?:\s*))([^;]+)/g, (_match, start, commaSeparatedKeyframes) => `${start}${ commaSeparatedKeyframes.split(',') .map( diff --git a/packages/compiler/test/shadow_css/keyframes_spec.ts b/packages/compiler/test/shadow_css/keyframes_spec.ts index 7e2ccfa3c9360..6c2cb898262ea 100644 --- a/packages/compiler/test/shadow_css/keyframes_spec.ts +++ b/packages/compiler/test/shadow_css/keyframes_spec.ts @@ -208,6 +208,30 @@ describe('ShadowCss, keyframes and animations', () => { expect(result).toContain('animation:1s "foo" , 2s "host-a_bar",3s "host-a_quux"'); }); + it('should correctly process animations defined without any prefixed space', () => { + let css = '.test{display: flex;animation:foo 1s forwards;} @keyframes foo {}'; + let expected = + '.test[host-a]{display: flex;animation:host-a_foo 1s forwards;} @keyframes host-a_foo {}'; + expect(s(css, 'host-a')).toEqual(expected); + css = '.test{animation:foo 2s forwards;} @keyframes foo {}'; + expected = '.test[host-a]{animation:host-a_foo 2s forwards;} @keyframes host-a_foo {}'; + expect(s(css, 'host-a')).toEqual(expected); + css = 'button {display: block;animation-name: foobar;} @keyframes foobar {}'; + expected = + 'button[host-a] {display: block;animation-name: host-a_foobar;} @keyframes host-a_foobar {}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + + it('should correctly process keyframes defined without any prefixed space', () => { + let css = '.test{display: flex;animation:bar 1s forwards;}@keyframes bar {}'; + let expected = + '.test[host-a]{display: flex;animation:host-a_bar 1s forwards;}@keyframes host-a_bar {}'; + expect(s(css, 'host-a')).toEqual(expected); + css = '.test{animation:bar 2s forwards;}@-webkit-keyframes bar {}'; + expected = '.test[host-a]{animation:host-a_bar 2s forwards;}@-webkit-keyframes host-a_bar {}'; + expect(s(css, 'host-a')).toEqual(expected); + }); + it('should ignore keywords values when scoping local animations', () => { const css = ` div {