fix(ivy): generate proper event listener names for animation events (FW-800) (#27525)

Prior to this change, animation event names were treated as a regular event names, stripping `@` symbol and event phase. As a result, event listeners were not invoked during animations. Now animation event name is formatted as needed and the necessary callbacks are invoked.

PR Close #27525
This commit is contained in:
Andrew Kushnir 2018-12-06 15:57:52 -08:00 committed by Alex Rickabaugh
parent 6316051967
commit dcb44e6dea
5 changed files with 171 additions and 119 deletions

View File

@ -239,6 +239,64 @@ describe('compiler compliance: styling', () => {
const result = compile(files, angularFiles); const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template'); expectEmit(result.source, template, 'Incorrect template');
}); });
it('should generate animation listeners', () => {
const files = {
app: {
'spec.ts': `
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'my-cmp',
template: \`
<div [@myAnimation]="exp"
(@myAnimation.start)="onStart($event)"
(@myAnimation.done)="onDone($event)"></div>
\`,
animations: [trigger(
'myAnimation',
[transition(
'* => state',
[style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])],
})
class MyComponent {
exp: any;
startEvent: any;
doneEvent: any;
onStart(event: any) { this.startEvent = event; }
onDone(event: any) { this.doneEvent = event; }
}
@NgModule({declarations: [MyComponent]})
export class MyModule {}
`
}
};
const template = `
MyComponent.ngComponentDef = $r3$.ɵdefineComponent({
consts: 1,
vars: 1,
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵelementStart(0, "div", _c0);
$r3$.ɵlistener("@myAnimation.start", function MyComponent_Template_div__myAnimation_start_listener($event) { return ctx.onStart($event); });
$r3$.ɵlistener("@myAnimation.done", function MyComponent_Template_div__myAnimation_done_listener($event) { return ctx.onDone($event); });
$r3$.ɵelementEnd();
} if (rf & 2) {
$r3$.ɵelementProperty(0, "@myAnimation", $r3$.ɵbind(ctx.exp));
}
},
encapsulation: 2,
});
`;
const result = compile(files, angularFiles);
expectEmit(result.source, template, 'Incorrect template');
});
}); });
describe('[style] and [style.prop]', () => { describe('[style] and [style.prop]', () => {

View File

@ -49,14 +49,14 @@ export class BoundAttribute implements Node {
export class BoundEvent implements Node { export class BoundEvent implements Node {
constructor( constructor(
public name: string, public handler: AST, public target: string|null, public name: string, public type: ParsedEventType, public handler: AST,
public phase: string|null, public sourceSpan: ParseSourceSpan) {} public target: string|null, public phase: string|null, public sourceSpan: ParseSourceSpan) {}
static fromParsedEvent(event: ParsedEvent) { static fromParsedEvent(event: ParsedEvent) {
const target: string|null = event.type === ParsedEventType.Regular ? event.targetOrPhase : null; const target: string|null = event.type === ParsedEventType.Regular ? event.targetOrPhase : null;
const phase: string|null = const phase: string|null =
event.type === ParsedEventType.Animation ? event.targetOrPhase : null; event.type === ParsedEventType.Animation ? event.targetOrPhase : null;
return new BoundEvent(event.name, event.handler, target, phase, event.sourceSpan); return new BoundEvent(event.name, event.type, event.handler, target, phase, event.sourceSpan);
} }
visit<Result>(visitor: Visitor<Result>): Result { return visitor.visitBoundEvent(this); } visit<Result>(visitor: Visitor<Result>): Result { return visitor.visitBoundEvent(this); }

View File

@ -10,7 +10,7 @@ import {flatten, sanitizeIdentifier} from '../../compile_metadata';
import {BindingForm, BuiltinFunctionCall, LocalResolver, convertActionBinding, convertPropertyBinding} from '../../compiler_util/expression_converter'; import {BindingForm, BuiltinFunctionCall, LocalResolver, convertActionBinding, convertPropertyBinding} from '../../compiler_util/expression_converter';
import {ConstantPool} from '../../constant_pool'; import {ConstantPool} from '../../constant_pool';
import * as core from '../../core'; import * as core from '../../core';
import {AST, AstMemoryEfficientTransformer, BindingPipe, BindingType, FunctionCall, ImplicitReceiver, Interpolation, LiteralArray, LiteralMap, LiteralPrimitive, PropertyRead} from '../../expression_parser/ast'; import {AST, AstMemoryEfficientTransformer, BindingPipe, BindingType, FunctionCall, ImplicitReceiver, Interpolation, LiteralArray, LiteralMap, LiteralPrimitive, ParsedEventType, PropertyRead} from '../../expression_parser/ast';
import {Lexer} from '../../expression_parser/lexer'; import {Lexer} from '../../expression_parser/lexer';
import {Parser} from '../../expression_parser/parser'; import {Parser} from '../../expression_parser/parser';
import * as i18n from '../../i18n/i18n_ast'; import * as i18n from '../../i18n/i18n_ast';
@ -987,7 +987,11 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
} }
private prepareListenerParameter(tagName: string, outputAst: t.BoundEvent): () => o.Expression[] { private prepareListenerParameter(tagName: string, outputAst: t.BoundEvent): () => o.Expression[] {
const evNameSanitized = sanitizeIdentifier(outputAst.name); let eventName: string = outputAst.name;
if (outputAst.type === ParsedEventType.Animation) {
eventName = prepareSyntheticAttributeName(`${outputAst.name}.${outputAst.phase}`);
}
const evNameSanitized = sanitizeIdentifier(eventName);
const tagNameSanitized = sanitizeIdentifier(tagName); const tagNameSanitized = sanitizeIdentifier(tagName);
const functionName = `${this.templateName}_${tagNameSanitized}_${evNameSanitized}_listener`; const functionName = `${this.templateName}_${tagNameSanitized}_${evNameSanitized}_listener`;
@ -1007,8 +1011,7 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const handler = o.fn( const handler = o.fn(
[new o.FnParam('$event', o.DYNAMIC_TYPE)], statements, o.INFERRED_TYPE, null, [new o.FnParam('$event', o.DYNAMIC_TYPE)], statements, o.INFERRED_TYPE, null,
functionName); functionName);
return [o.literal(eventName), handler];
return [o.literal(outputAst.name), handler];
}; };
} }
} }

View File

@ -120,43 +120,42 @@ import {el} from '../../testing/src/browser_util';
// these tests are only mean't to be run within the DOM // these tests are only mean't to be run within the DOM
if (isNode) return; if (isNode) return;
fixmeIvy(`FW-800: Animation listeners are not invoked`) it('should flush and fire callbacks when the zone becomes stable', (async) => {
.it('should flush and fire callbacks when the zone becomes stable', (async) => { @Component({
@Component({ selector: 'my-cmp',
selector: 'my-cmp', template: '<div [@myAnimation]="exp" (@myAnimation.start)="onStart($event)"></div>',
template: '<div [@myAnimation]="exp" (@myAnimation.start)="onStart($event)"></div>', animations: [trigger(
animations: [trigger( 'myAnimation',
'myAnimation', [transition(
[transition( '* => state',
'* => state', [style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])],
[style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])], })
}) class Cmp {
class Cmp { exp: any;
exp: any; event: any;
event: any; onStart(event: any) { this.event = event; }
onStart(event: any) { this.event = event; } }
}
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}], providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}],
declarations: [Cmp] 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 = 'state'; cmp.exp = 'state';
fixture.detectChanges(); fixture.detectChanges();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
expect(cmp.event.triggerName).toEqual('myAnimation'); expect(cmp.event.triggerName).toEqual('myAnimation');
expect(cmp.event.phaseName).toEqual('start'); expect(cmp.event.phaseName).toEqual('start');
cmp.event = null; cmp.event = null;
engine.flush(); engine.flush();
expect(cmp.event).toBeFalsy(); expect(cmp.event).toBeFalsy();
async(); async();
}); });
}); });
it('should properly insert/remove nodes through the animation renderer that do not contain animations', it('should properly insert/remove nodes through the animation renderer that do not contain animations',
(async) => { (async) => {

View File

@ -10,94 +10,86 @@ import {ɵAnimationEngine} from '@angular/animations/browser';
import {Component} from '@angular/core'; import {Component} from '@angular/core';
import {TestBed} from '@angular/core/testing'; import {TestBed} from '@angular/core/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {fixmeIvy} from '@angular/private/testing';
{ {
describe('NoopAnimationsModule', () => { describe('NoopAnimationsModule', () => {
beforeEach(() => { TestBed.configureTestingModule({imports: [NoopAnimationsModule]}); }); beforeEach(() => { TestBed.configureTestingModule({imports: [NoopAnimationsModule]}); });
it('should be removed once FW-800 is fixed', () => { expect(true).toBeTruthy(); }); it('should flush and fire callbacks when the zone becomes stable', (async) => {
@Component({
selector: 'my-cmp',
template:
'<div [@myAnimation]="exp" (@myAnimation.start)="onStart($event)" (@myAnimation.done)="onDone($event)"></div>',
animations: [trigger(
'myAnimation',
[transition(
'* => state', [style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])],
})
class Cmp {
exp: any;
startEvent: any;
doneEvent: any;
onStart(event: any) { this.startEvent = event; }
onDone(event: any) { this.doneEvent = event; }
}
// TODO: remove the dummy test above ^ once the bug FW-800 has been fixed TestBed.configureTestingModule({declarations: [Cmp]});
fixmeIvy(`FW-800: Animation listeners are not invoked`)
.it('should flush and fire callbacks when the zone becomes stable', (async) => {
@Component({
selector: 'my-cmp',
template:
'<div [@myAnimation]="exp" (@myAnimation.start)="onStart($event)" (@myAnimation.done)="onDone($event)"></div>',
animations: [trigger(
'myAnimation',
[transition(
'* => state',
[style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])],
})
class Cmp {
exp: any;
startEvent: any;
doneEvent: any;
onStart(event: any) { this.startEvent = event; }
onDone(event: any) { this.doneEvent = event; }
}
TestBed.configureTestingModule({declarations: [Cmp]}); const fixture = TestBed.createComponent(Cmp);
const cmp = fixture.componentInstance;
cmp.exp = 'state';
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(cmp.startEvent.triggerName).toEqual('myAnimation');
expect(cmp.startEvent.phaseName).toEqual('start');
expect(cmp.doneEvent.triggerName).toEqual('myAnimation');
expect(cmp.doneEvent.phaseName).toEqual('done');
async();
});
});
const fixture = TestBed.createComponent(Cmp); it('should handle leave animation callbacks even if the element is destroyed in the process',
const cmp = fixture.componentInstance; (async) => {
cmp.exp = 'state'; @Component({
fixture.detectChanges(); selector: 'my-cmp',
fixture.whenStable().then(() => { template:
expect(cmp.startEvent.triggerName).toEqual('myAnimation'); '<div *ngIf="exp" @myAnimation (@myAnimation.start)="onStart($event)" (@myAnimation.done)="onDone($event)"></div>',
expect(cmp.startEvent.phaseName).toEqual('start'); animations: [trigger(
expect(cmp.doneEvent.triggerName).toEqual('myAnimation'); 'myAnimation',
expect(cmp.doneEvent.phaseName).toEqual('done'); [transition(
async(); ':leave', [style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])],
}); })
}); class Cmp {
exp: any;
startEvent: any;
doneEvent: any;
onStart(event: any) { this.startEvent = event; }
onDone(event: any) { this.doneEvent = event; }
}
fixmeIvy(`FW-800: Animation listeners are not invoked`) TestBed.configureTestingModule({declarations: [Cmp]});
.it('should handle leave animation callbacks even if the element is destroyed in the process', const engine = TestBed.get(ɵAnimationEngine);
(async) => { const fixture = TestBed.createComponent(Cmp);
@Component({ const cmp = fixture.componentInstance;
selector: 'my-cmp',
template:
'<div *ngIf="exp" @myAnimation (@myAnimation.start)="onStart($event)" (@myAnimation.done)="onDone($event)"></div>',
animations: [trigger(
'myAnimation',
[transition(
':leave',
[style({'opacity': '0'}), animate(500, style({'opacity': '1'}))])])],
})
class Cmp {
exp: any;
startEvent: any;
doneEvent: any;
onStart(event: any) { this.startEvent = event; }
onDone(event: any) { this.doneEvent = event; }
}
TestBed.configureTestingModule({declarations: [Cmp]}); cmp.exp = true;
const engine = TestBed.get(ɵAnimationEngine); fixture.detectChanges();
const fixture = TestBed.createComponent(Cmp); fixture.whenStable().then(() => {
const cmp = fixture.componentInstance; cmp.startEvent = null;
cmp.doneEvent = null;
cmp.exp = true; cmp.exp = false;
fixture.detectChanges(); fixture.detectChanges();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
cmp.startEvent = null; expect(cmp.startEvent.triggerName).toEqual('myAnimation');
cmp.doneEvent = null; expect(cmp.startEvent.phaseName).toEqual('start');
expect(cmp.startEvent.toState).toEqual('void');
cmp.exp = false; expect(cmp.doneEvent.triggerName).toEqual('myAnimation');
fixture.detectChanges(); expect(cmp.doneEvent.phaseName).toEqual('done');
fixture.whenStable().then(() => { expect(cmp.doneEvent.toState).toEqual('void');
expect(cmp.startEvent.triggerName).toEqual('myAnimation'); async();
expect(cmp.startEvent.phaseName).toEqual('start'); });
expect(cmp.startEvent.toState).toEqual('void'); });
expect(cmp.doneEvent.triggerName).toEqual('myAnimation'); });
expect(cmp.doneEvent.phaseName).toEqual('done');
expect(cmp.doneEvent.toState).toEqual('void');
async();
});
});
});
}); });
} }