fix(ivy): ensure animation @bindings work for {key:value} and empty bindings (#28026)

PR Close #28026
This commit is contained in:
Matias Niemelä
2019-01-09 13:40:13 -08:00
committed by Andrew Kushnir
parent 0136274f33
commit 94c0b7a362
3 changed files with 314 additions and 306 deletions

View File

@ -688,10 +688,19 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const instruction = mapBindingToInstruction(input.type); const instruction = mapBindingToInstruction(input.type);
if (input.type === BindingType.Animation) { if (input.type === BindingType.Animation) {
const value = input.value.visit(this._valueConverter); const value = input.value.visit(this._valueConverter);
// setProperty without a value doesn't make any sense // animation bindings can be presented in the following formats:
if (value.name || value.value) { // 1j [@binding]="fooExp"
const bindingName = prepareSyntheticPropertyName(input.name); // 2. [@binding]="{value:fooExp, params:{...}}"
// 3. [@binding]
// 4. @binding
// only formats 1. and 2. include the actual binding of a value to
// an expression and therefore only those should be the only two that
// are allowed. The check below ensures that a binding with no expression
// does not get an empty `elementProperty` instruction created for it.
const hasValue = value && (value instanceof LiteralPrimitive) ? !!value.value : true;
if (hasValue) {
this.allocateBindingSlots(value); this.allocateBindingSlots(value);
const bindingName = prepareSyntheticPropertyName(input.name);
this.updateInstruction(input.sourceSpan, R3.elementProperty, () => { this.updateInstruction(input.sourceSpan, R3.elementProperty, () => {
return [ return [
o.literal(elementIndex), o.literal(bindingName), o.literal(elementIndex), o.literal(bindingName),

View File

@ -373,48 +373,48 @@ const DEFAULT_COMPONENT_ID = '1';
expect(players.length).toEqual(0); expect(players.length).toEqual(0);
}); });
fixmeIvy('unknown').it( it('should allow a transition to use a function to determine what method to run and expose any parameter values',
'should allow a transition to use a function to determine what method to run and expose any parameter values', () => {
() => { const transitionFn =
const transitionFn = (fromState: string, toState: string, element: any, params: {[key: string]: any}) => {
(fromState: string, toState: string, element: any, return params['doMatch'] == true;
params: {[key: string]: any}) => { return params['doMatch'] == true; }; };
@Component({ @Component({
selector: 'if-cmp', selector: 'if-cmp',
template: '<div [@myAnimation]="{value:exp, params: {doMatch:doMatch}}"></div>', template: '<div [@myAnimation]="{value:exp, params: {doMatch:doMatch}}"></div>',
animations: [ animations: [
trigger( trigger(
'myAnimation', 'myAnimation',
[transition( [transition(
transitionFn, [style({opacity: 0}), animate(3333, style({opacity: 1}))])]), transitionFn, [style({opacity: 0}), animate(3333, style({opacity: 1}))])]),
] ]
}) })
class Cmp { class Cmp {
doMatch = false; doMatch = false;
exp: any = ''; exp: any = '';
} }
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
cmp.doMatch = true; cmp.doMatch = true;
fixture.detectChanges(); fixture.detectChanges();
let players = getLog(); let players = getLog();
expect(players.length).toEqual(1); expect(players.length).toEqual(1);
let [p1] = players; let [p1] = players;
expect(p1.totalTime).toEqual(3333); expect(p1.totalTime).toEqual(3333);
resetLog(); resetLog();
cmp.doMatch = false; cmp.doMatch = false;
cmp.exp = 'this-wont-match'; cmp.exp = 'this-wont-match';
fixture.detectChanges(); fixture.detectChanges();
players = getLog(); players = getLog();
expect(players.length).toEqual(0); expect(players.length).toEqual(0);
}); });
it('should allow a state value to be `0`', () => { it('should allow a state value to be `0`', () => {
@Component({ @Component({
@ -1567,62 +1567,64 @@ const DEFAULT_COMPONENT_ID = '1';
} }
}); });
fixmeIvy('FW-932: Animation @triggers are not reported to the renderer in Ivy as they are in VE').it( fixmeIvy(
'should animate removals of nodes to the `void` state for each animation trigger, but treat all auto styles as pre styles', 'FW-932: Animation @triggers are not reported to the renderer in Ivy as they are in VE')
() => { .it('should animate removals of nodes to the `void` state for each animation trigger, but treat all auto styles as pre styles',
@Component({ () => {
selector: 'ani-cmp', @Component({
template: ` selector: 'ani-cmp',
template: `
<div *ngIf="exp" class="ng-if" [@trig1]="exp2" @trig2></div> <div *ngIf="exp" class="ng-if" [@trig1]="exp2" @trig2></div>
`, `,
animations: [ animations: [
trigger( trigger('trig1', [transition(
'trig1', [transition('state => void', [animate(1000, style({opacity: 0}))])]), 'state => void', [animate(1000, style({opacity: 0}))])]),
trigger('trig2', [transition(':leave', [animate(1000, style({width: '0px'}))])]) trigger(
] 'trig2', [transition(':leave', [animate(1000, style({width: '0px'}))])])
}) ]
class Cmp { })
public exp = true; class Cmp {
public exp2 = 'state'; public exp = true;
} public exp2 = 'state';
}
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine); const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
cmp.exp = true; cmp.exp = true;
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
resetLog(); resetLog();
const element = getDOM().querySelector(fixture.nativeElement, '.ng-if'); const element = getDOM().querySelector(fixture.nativeElement, '.ng-if');
assertHasParent(element, true); assertHasParent(element, true);
cmp.exp = false; cmp.exp = false;
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
assertHasParent(element, true); assertHasParent(element, true);
expect(getLog().length).toEqual(2); expect(getLog().length).toEqual(2);
const player2 = getLog().pop() !; const player2 = getLog().pop() !;
const player1 = getLog().pop() !; const player1 = getLog().pop() !;
expect(player2.keyframes).toEqual([ expect(player2.keyframes).toEqual([
{width: PRE_STYLE, offset: 0}, {width: PRE_STYLE, offset: 0},
{width: '0px', offset: 1}, {width: '0px', offset: 1},
]); ]);
expect(player1.keyframes).toEqual([ expect(player1.keyframes).toEqual([
{opacity: PRE_STYLE, offset: 0}, {opacity: '0', offset: 1} {opacity: PRE_STYLE, offset: 0}, {opacity: '0', offset: 1}
]); ]);
player2.finish(); player2.finish();
player1.finish(); player1.finish();
assertHasParent(element, false); assertHasParent(element, false);
}); });
it('should properly cancel all existing animations when a removal occurs', () => { it('should properly cancel all existing animations when a removal occurs', () => {
@Component({ @Component({
@ -1926,147 +1928,146 @@ const DEFAULT_COMPONENT_ID = '1';
expect(p.contains(c2)).toBeTruthy(); expect(p.contains(c2)).toBeTruthy();
}); });
fixmeIvy('unknown').it( it('should detect trigger changes based on object.value properties', () => {
'should detect trigger changes based on object.value properties', () => { @Component({
@Component({ selector: 'ani-cmp',
selector: 'ani-cmp', template: `
template: `
<div [@myAnimation]="{value:exp}"></div> <div [@myAnimation]="{value:exp}"></div>
`, `,
animations: [ animations: [
trigger( trigger(
'myAnimation', 'myAnimation',
[ [
transition('* => 1', [animate(1234, style({opacity: 0}))]), transition('* => 1', [animate(1234, style({opacity: 0}))]),
transition('* => 2', [animate(5678, style({opacity: 0}))]), transition('* => 2', [animate(5678, style({opacity: 0}))]),
]), ]),
] ]
}) })
class Cmp { class Cmp {
public exp: any; public exp: any;
} }
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine); const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
cmp.exp = '1'; cmp.exp = '1';
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
let players = getLog(); let players = getLog();
expect(players.length).toEqual(1); expect(players.length).toEqual(1);
expect(players[0].duration).toEqual(1234); expect(players[0].duration).toEqual(1234);
resetLog(); resetLog();
cmp.exp = '2'; cmp.exp = '2';
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
players = getLog(); players = getLog();
expect(players.length).toEqual(1); expect(players.length).toEqual(1);
expect(players[0].duration).toEqual(5678); expect(players[0].duration).toEqual(5678);
}); });
fixmeIvy('FW-932: Animation @triggers are not reported to the renderer in Ivy as they are in VE').it( fixmeIvy(
'should not render animations when the object expression value is the same as it was previously', 'FW-932: Animation @triggers are not reported to the renderer in Ivy as they are in VE')
() => { .it('should not render animations when the object expression value is the same as it was previously',
@Component({ () => {
selector: 'ani-cmp', @Component({
template: ` selector: 'ani-cmp',
template: `
<div [@myAnimation]="{value:exp,params:params}"></div> <div [@myAnimation]="{value:exp,params:params}"></div>
`, `,
animations: [ animations: [
trigger( trigger(
'myAnimation', 'myAnimation',
[ [
transition('* => *', [animate(1234, style({opacity: 0}))]), transition('* => *', [animate(1234, style({opacity: 0}))]),
]), ]),
] ]
}) })
class Cmp { class Cmp {
public exp: any; public exp: any;
public params: any; public params: any;
} }
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine); const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
cmp.exp = '1'; cmp.exp = '1';
cmp.params = {}; cmp.params = {};
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
let players = getLog(); let players = getLog();
expect(players.length).toEqual(1); expect(players.length).toEqual(1);
expect(players[0].duration).toEqual(1234); expect(players[0].duration).toEqual(1234);
resetLog(); resetLog();
cmp.exp = '1'; cmp.exp = '1';
cmp.params = {}; cmp.params = {};
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
players = getLog(); players = getLog();
expect(players.length).toEqual(0); expect(players.length).toEqual(0);
}); });
fixmeIvy('unknown').it( it('should update the final state styles when params update even if the expression hasn\'t changed',
'should update the final state styles when params update even if the expression hasn\'t changed', fakeAsync(() => {
fakeAsync(() => { @Component({
@Component({ selector: 'ani-cmp',
selector: 'ani-cmp', template: `
template: `
<div [@myAnimation]="{value:exp,params:{color:color}}"></div> <div [@myAnimation]="{value:exp,params:{color:color}}"></div>
`, `,
animations: [ animations: [
trigger( trigger(
'myAnimation', 'myAnimation',
[ [
state('*', style({color: '{{ color }}'}), {params: {color: 'black'}}), state('*', style({color: '{{ color }}'}), {params: {color: 'black'}}),
transition('* => 1', animate(500)) transition('* => 1', animate(500))
]), ]),
] ]
}) })
class Cmp { class Cmp {
public exp: any; public exp: any;
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
public color !: string | null; public color !: string | null;
} }
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine); const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
cmp.exp = '1'; cmp.exp = '1';
cmp.color = 'red'; cmp.color = 'red';
fixture.detectChanges(); fixture.detectChanges();
const player = getLog()[0] !; const player = getLog()[0] !;
const element = player.element; const element = player.element;
player.finish(); player.finish();
flushMicrotasks(); flushMicrotasks();
expect(getDOM().hasStyle(element, 'color', 'red')).toBeTruthy(); expect(getDOM().hasStyle(element, 'color', 'red')).toBeTruthy();
cmp.exp = '1'; cmp.exp = '1';
cmp.color = 'blue'; cmp.color = 'blue';
fixture.detectChanges(); fixture.detectChanges();
resetLog(); resetLog();
flushMicrotasks(); flushMicrotasks();
expect(getDOM().hasStyle(element, 'color', 'blue')).toBeTruthy(); expect(getDOM().hasStyle(element, 'color', 'blue')).toBeTruthy();
cmp.exp = '1'; cmp.exp = '1';
cmp.color = null; cmp.color = null;
fixture.detectChanges(); fixture.detectChanges();
resetLog(); resetLog();
flushMicrotasks(); flushMicrotasks();
expect(getDOM().hasStyle(element, 'color', 'black')).toBeTruthy(); expect(getDOM().hasStyle(element, 'color', 'black')).toBeTruthy();
})); }));
it('should substitute in values if the provided state match is an object with values', () => { it('should substitute in values if the provided state match is an object with values', () => {
@Component({ @Component({
@ -2105,73 +2106,72 @@ const DEFAULT_COMPONENT_ID = '1';
]); ]);
}); });
fixmeIvy('unknown').it( it('should retain substituted styles on the element once the animation is complete if referenced in the final state',
'should retain substituted styles on the element once the animation is complete if referenced in the final state', fakeAsync(() => {
fakeAsync(() => { @Component({
@Component({ selector: 'ani-cmp',
selector: 'ani-cmp', template: `
template: `
<div [@myAnimation]="{value:exp, params: { color: color }}"></div> <div [@myAnimation]="{value:exp, params: { color: color }}"></div>
`, `,
animations: [ animations: [
trigger( trigger(
'myAnimation', 'myAnimation',
[ [
state( state(
'start', style({ 'start', style({
color: '{{ color }}', color: '{{ color }}',
fontSize: '{{ fontSize }}px', fontSize: '{{ fontSize }}px',
width: '{{ width }}' width: '{{ width }}'
}), }),
{params: {color: 'red', fontSize: '200', width: '10px'}}), {params: {color: 'red', fontSize: '200', width: '10px'}}),
state( state(
'final', 'final',
style( style(
{color: '{{ color }}', fontSize: '{{ fontSize }}px', width: '888px'}), {color: '{{ color }}', fontSize: '{{ fontSize }}px', width: '888px'}),
{params: {color: 'green', fontSize: '50', width: '100px'}}), {params: {color: 'green', fontSize: '50', width: '100px'}}),
transition('start => final', animate(500)), transition('start => final', animate(500)),
]), ]),
] ]
}) })
class Cmp { class Cmp {
public exp: any; public exp: any;
public color: any; public color: any;
} }
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine); const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
cmp.exp = 'start'; cmp.exp = 'start';
cmp.color = 'red'; cmp.color = 'red';
fixture.detectChanges(); fixture.detectChanges();
resetLog(); resetLog();
cmp.exp = 'final'; cmp.exp = 'final';
cmp.color = 'blue'; cmp.color = 'blue';
fixture.detectChanges(); fixture.detectChanges();
const players = getLog(); const players = getLog();
expect(players.length).toEqual(1); expect(players.length).toEqual(1);
const [p1] = players; const [p1] = players;
expect(p1.keyframes).toEqual([ expect(p1.keyframes).toEqual([
{color: 'red', fontSize: '200px', width: '10px', offset: 0}, {color: 'red', fontSize: '200px', width: '10px', offset: 0},
{color: 'blue', fontSize: '50px', width: '888px', offset: 1} {color: 'blue', fontSize: '50px', width: '888px', offset: 1}
]); ]);
const element = p1.element; const element = p1.element;
p1.finish(); p1.finish();
flushMicrotasks(); flushMicrotasks();
expect(getDOM().hasStyle(element, 'color', 'blue')).toBeTruthy(); expect(getDOM().hasStyle(element, 'color', 'blue')).toBeTruthy();
expect(getDOM().hasStyle(element, 'fontSize', '50px')).toBeTruthy(); expect(getDOM().hasStyle(element, 'fontSize', '50px')).toBeTruthy();
expect(getDOM().hasStyle(element, 'width', '888px')).toBeTruthy(); expect(getDOM().hasStyle(element, 'width', '888px')).toBeTruthy();
})); }));
it('should only evaluate final state param substitutions from the expression and state values and not from the transition options ', it('should only evaluate final state param substitutions from the expression and state values and not from the transition options ',
fakeAsync(() => { fakeAsync(() => {

View File

@ -2444,28 +2444,27 @@ import {HostListener} from '../../src/metadata/directives';
expect(element.innerText.trim()).toMatch(/this\s+child/mg); expect(element.innerText.trim()).toMatch(/this\s+child/mg);
})); }));
fixmeIvy('unknown').it( it('should only mark outermost *directive nodes :enter and :leave when inserts and removals occur',
'should only mark outermost *directive nodes :enter and :leave when inserts and removals occur', () => {
() => { @Component({
@Component({ selector: 'ani-cmp',
selector: 'ani-cmp', animations: [
animations: [ trigger(
trigger( 'anim',
'anim', [
[ transition(
transition( '* => enter',
'* => enter', [
[ query(':enter', [animate(1000, style({color: 'red'}))]),
query(':enter', [animate(1000, style({color: 'red'}))]), ]),
]), transition(
transition( '* => leave',
'* => leave', [
[ query(':leave', [animate(1000, style({color: 'blue'}))]),
query(':leave', [animate(1000, style({color: 'blue'}))]), ]),
]), ]),
]), ],
], template: `
template: `
<section class="container" [@anim]="exp ? 'enter' : 'leave'"> <section class="container" [@anim]="exp ? 'enter' : 'leave'">
<div class="a" *ngIf="exp"> <div class="a" *ngIf="exp">
<div class="b" *ngIf="exp"> <div class="b" *ngIf="exp">
@ -2481,43 +2480,43 @@ import {HostListener} from '../../src/metadata/directives';
</div> </div>
</section> </section>
` `
}) })
class Cmp { class Cmp {
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
public exp !: boolean; public exp !: boolean;
} }
TestBed.configureTestingModule({declarations: [Cmp]}); TestBed.configureTestingModule({declarations: [Cmp]});
const engine = TestBed.get(ɵAnimationEngine); const engine = TestBed.get(ɵAnimationEngine);
const fixture = TestBed.createComponent(Cmp); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance; const cmp = fixture.componentInstance;
const container = fixture.elementRef.nativeElement; const container = fixture.elementRef.nativeElement;
cmp.exp = true; cmp.exp = true;
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
let players = getLog(); let players = getLog();
resetLog(); resetLog();
expect(players.length).toEqual(2); expect(players.length).toEqual(2);
const [p1, p2] = players; const [p1, p2] = players;
expect(p1.element.classList.contains('a')); expect(p1.element.classList.contains('a'));
expect(p2.element.classList.contains('d')); expect(p2.element.classList.contains('d'));
cmp.exp = false; cmp.exp = false;
fixture.detectChanges(); fixture.detectChanges();
engine.flush(); engine.flush();
players = getLog(); players = getLog();
resetLog(); resetLog();
expect(players.length).toEqual(2); expect(players.length).toEqual(2);
const [p3, p4] = players; const [p3, p4] = players;
expect(p3.element.classList.contains('a')); expect(p3.element.classList.contains('a'));
expect(p4.element.classList.contains('d')); expect(p4.element.classList.contains('d'));
}); });
it('should collect multiple root levels of :enter and :leave nodes', () => { it('should collect multiple root levels of :enter and :leave nodes', () => {
@Component({ @Component({