Skip to content

Commit 20397b6

Browse files
committed
fix(core): resolve var() in css animations and keyframes per view
Animations were built once per rule from its raw declarations, before any variable was resolved, so `animation: var(--animate-spin)` (how Tailwind's `animate-*` utilities are written) and `animation-duration: var(--speed)` never played. A `var()` inside `@keyframes` failed to convert and the exception stopped the rest of the stylesheet being applied. A rule whose animation declarations use variables, and keyframes whose values use them, are now resolved against each view when its animations play. Everything else is still parsed once. Keyframes that cannot be converted are skipped with a trace warning instead of throwing.
1 parent aeac327 commit 20397b6

2 files changed

Lines changed: 180 additions & 6 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
3+
import { StyleScope } from './style-scope';
4+
import { StackLayout } from '../layouts/stack-layout';
5+
import { Label } from '../label';
6+
import { KeyframeAnimation } from '../animation/keyframe-animation';
7+
8+
function play(css: string, className?: string): KeyframeAnimation[] {
9+
const played: KeyframeAnimation[] = [];
10+
vi.spyOn(KeyframeAnimation.prototype, 'play').mockImplementation(function (this: KeyframeAnimation) {
11+
played.push(this);
12+
return Promise.resolve();
13+
});
14+
15+
const scope = new StyleScope();
16+
scope.css = css;
17+
const root: any = new StackLayout();
18+
root._styleScope = scope;
19+
const view: any = new Label();
20+
root.addChild(view);
21+
view._styleScope = scope;
22+
if (className) {
23+
view.className = className;
24+
}
25+
view._cssState.onLoaded();
26+
27+
return played;
28+
}
29+
30+
describe('css animations using variables', () => {
31+
afterEach(() => vi.restoreAllMocks());
32+
33+
it('resolve an animation shorthand held in a variable', () => {
34+
const [animation] = play(`
35+
@keyframes spin { from { opacity: 0; } to { opacity: 1; } }
36+
label { --animate-spin: spin 2s linear infinite; animation: var(--animate-spin); }
37+
`);
38+
39+
expect(animation).toBeDefined();
40+
expect(animation.iterations).toBe(Number.POSITIVE_INFINITY);
41+
expect(animation.animations.map((a) => a.opacity)).toEqual([0, 1]);
42+
});
43+
44+
it('resolve animation longhands per view', () => {
45+
const played = play(
46+
`
47+
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
48+
label { animation-name: fade; animation-duration: var(--speed); }
49+
.slow { --speed: 4s; }
50+
`,
51+
'slow',
52+
);
53+
54+
expect(played.length).toBe(1);
55+
expect(played[0].animations[1].duration).toBe(4000);
56+
});
57+
58+
it('resolve variables inside keyframes against the view', () => {
59+
const [animation] = play(`
60+
@keyframes pulse { from { opacity: var(--from); } to { opacity: var(--to, 1); } }
61+
label { --from: 0.25; animation: pulse 1s; }
62+
`);
63+
64+
expect(animation.animations.map((a) => a.opacity)).toEqual([0.25, 1]);
65+
});
66+
67+
it('keep styling the view when keyframes cannot be resolved', () => {
68+
const played = play(`
69+
@keyframes broken { from { opacity: var(--missing); } to { opacity: 1; } }
70+
label { color: red; animation: broken 1s; }
71+
`);
72+
73+
expect(played.length).toBe(0);
74+
});
75+
76+
it('still play animations without variables', () => {
77+
const [animation] = play(`
78+
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
79+
label { animation: fade 1s; }
80+
`);
81+
82+
expect(animation.animations.map((a) => a.opacity)).toEqual([0, 1]);
83+
});
84+
});

‎packages/core/ui/styling/style-scope.ts‎

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { File, knownFolders, path } from '../../file-system';
1111
import { Application, CssChangedEventData, LoadAppCSSEventData } from '../../application';
1212
import { profile } from './styling-profile';
1313

14-
import { Keyframes, KeyframeAnimationInfo, KeyframeAnimation } from '../animation/keyframe-animation';
14+
import { Keyframes, KeyframeAnimationInfo, KeyframeAnimation, KeyframeInfo } from '../animation/keyframe-animation';
1515

1616
import { CssAnimationParser } from './css-animation-parser';
1717
import { sanitizeModuleName } from '../../utils/common';
@@ -66,6 +66,39 @@ const tagToScopeTag: Map<string | number, string> = new Map();
6666
let currentScopeTag: string = null;
6767

6868
const animationsSymbol = Symbol('animations');
69+
// Set on a ruleset whose animation declarations use var(): its animations are resolved per view.
70+
const animationVariablesSymbol = Symbol('animationVariables');
71+
// Set on an animation whose keyframes use var(): the raw keyframes, resolved per view.
72+
const keyframeVariablesSymbol = Symbol('keyframeVariables');
73+
74+
function isAnimationDeclaration(declaration: { property: string }): boolean {
75+
return declaration.property.startsWith('animation');
76+
}
77+
78+
function keyframesUseVariables(keyframes: Keyframes): boolean {
79+
return keyframes.keyframes.some((keyframe) => keyframe.declarations.some((declaration) => typeof declaration.value === 'string' && isCssVariableExpression(declaration.value)));
80+
}
81+
82+
/**
83+
* Convert keyframes, or null if a value cannot be: one bad keyframe must not stop the stylesheet.
84+
*/
85+
function keyframesArrayFromCSS(keyframes: Keyframes, view?: ViewBase): KeyframeInfo[] | null {
86+
let unparsed = keyframes.keyframes;
87+
if (view) {
88+
unparsed = unparsed.map((keyframe) => ({
89+
values: keyframe.values,
90+
declarations: keyframe.declarations.map(({ property, value }) => ({ property, value: _evaluateCssVariableExpression(view, property, value) })),
91+
}));
92+
}
93+
94+
try {
95+
return CssAnimationParser.keyframesArrayFromCSS(unparsed);
96+
} catch (e) {
97+
Trace.write(`Failed to parse keyframes [${keyframes.name}]${view ? ` for ${view}` : ''}. ${e}`, Trace.categories.Animation, Trace.messageType.warn);
98+
99+
return null;
100+
}
101+
}
69102
const kebabCasePattern = /-([a-z])/g;
70103
const kebabCaseReplacementFunc = (g: string) => g[1].toUpperCase();
71104
const pattern = /('|")(.*?)\1/;
@@ -373,6 +406,9 @@ class CSSSource {
373406
if (rulesets && rulesets.length) {
374407
rulesets.forEach((rule) => {
375408
rule[animationsSymbol] = CssAnimationParser.keyframeAnimationsFromCSSDeclarations(rule.declarations);
409+
if (rule[animationsSymbol] && rule.declarations.some((declaration) => isAnimationDeclaration(declaration) && isCssVariableExpression(declaration.value))) {
410+
rule[animationVariablesSymbol] = true;
411+
}
376412
});
377413
}
378414

@@ -798,9 +834,16 @@ export class CssState {
798834
const animations: KeyframeAnimation[] = [];
799835

800836
matchingSelectors.forEach((selector) => {
801-
const ruleAnimations: KeyframeAnimationInfo[] = selector.ruleset?.[animationsSymbol];
837+
const ruleset = selector.ruleset;
838+
let ruleAnimations: KeyframeAnimationInfo[] = ruleset?.[animationsSymbol];
839+
if (ruleAnimations && ruleset[animationVariablesSymbol]) {
840+
ruleAnimations = this.resolveAnimationVariables(ruleset);
841+
}
802842
if (ruleAnimations) {
803-
for (const animationInfo of ruleAnimations) {
843+
for (let animationInfo of ruleAnimations) {
844+
if (animationInfo[keyframeVariablesSymbol]) {
845+
animationInfo = this.resolveKeyframeVariables(animationInfo);
846+
}
804847
const animation = KeyframeAnimation.keyframeAnimationFromInfo(animationInfo);
805848
if (animation) {
806849
animations.push(animation);
@@ -823,6 +866,38 @@ export class CssState {
823866
}
824867
}
825868

869+
/**
870+
* The ruleset's animations, with the variables in their declarations resolved for this view.
871+
*/
872+
private resolveAnimationVariables(ruleset: RuleSet): KeyframeAnimationInfo[] {
873+
const view = this.viewRef.get();
874+
const scope = view?._styleScope;
875+
if (!scope) {
876+
return null;
877+
}
878+
879+
const declarations = ruleset.declarations.filter(isAnimationDeclaration).map(({ property, value }) => ({ property, value: _evaluateCssVariableExpression(view, property, value) }));
880+
const animations = CssAnimationParser.keyframeAnimationsFromCSSDeclarations(declarations);
881+
if (animations) {
882+
for (const animation of animations) {
883+
scope._applyKeyframes(animation);
884+
}
885+
}
886+
887+
return animations;
888+
}
889+
890+
/**
891+
* A copy of the animation with its keyframes' variables resolved for this view.
892+
*/
893+
private resolveKeyframeVariables(animation: KeyframeAnimationInfo): KeyframeAnimationInfo {
894+
const view = this.viewRef.get();
895+
const resolved = Object.assign(new KeyframeAnimationInfo(), animation);
896+
resolved.keyframes = view ? keyframesArrayFromCSS(animation[keyframeVariablesSymbol], view) : null;
897+
898+
return resolved;
899+
}
900+
826901
private stopKeyframeAnimations(): void {
827902
if (!this._playsKeyframeAnimations) {
828903
return;
@@ -1168,7 +1243,7 @@ export class StyleScope {
11681243
const keyframeRule = this.findKeyframeRule(animationName);
11691244

11701245
const animation = new KeyframeAnimationInfo();
1171-
animation.keyframes = keyframeRule ? CssAnimationParser.keyframesArrayFromCSS(keyframeRule.keyframes) : null;
1246+
animation.keyframes = keyframeRule ? keyframesArrayFromCSS(keyframeRule) : null;
11721247

11731248
return animation;
11741249
}
@@ -1292,13 +1367,28 @@ export class StyleScope {
12921367

12931368
if (animations != null && animations.length) {
12941369
for (const animation of animations) {
1295-
const keyframeRule = this.findKeyframeRule(animation.name);
1296-
animation.keyframes = keyframeRule ? CssAnimationParser.keyframesArrayFromCSS(keyframeRule.keyframes) : null;
1370+
this._applyKeyframes(animation);
12971371
}
12981372
}
12991373
}
13001374
}
13011375

1376+
/**
1377+
* Give the animation the keyframes of its name. Keyframes that use variables are kept
1378+
* unparsed, for each view to resolve.
1379+
* @private
1380+
*/
1381+
public _applyKeyframes(animation: KeyframeAnimationInfo): void {
1382+
const keyframeRule = this.findKeyframeRule(animation.name);
1383+
if (keyframeRule && keyframesUseVariables(keyframeRule)) {
1384+
animation[keyframeVariablesSymbol] = keyframeRule;
1385+
animation.keyframes = null;
1386+
} else {
1387+
animation[keyframeVariablesSymbol] = undefined;
1388+
animation.keyframes = keyframeRule ? keyframesArrayFromCSS(keyframeRule) : null;
1389+
}
1390+
}
1391+
13021392
public getAnimations(ruleset: RuleSet): KeyframeAnimationInfo[] {
13031393
return ruleset[animationsSymbol];
13041394
}

0 commit comments

Comments
 (0)