From 184d2707256a41d6b3618331cbfb453d38b49d11 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 31 Jul 2019 07:23:47 +0300 Subject: [PATCH] fix(ivy): DebugElement.triggerEventHandler not picking up events registered via Renderer2 (#31845) Fixes Ivy's `DebugElement.triggerEventHandler` to picking up events that have been registered through a `Renderer2`, unlike ViewEngine. This PR resolves FW-1480. PR Close #31845 --- packages/core/src/debug/debug_node.ts | 21 ++++++- .../core/src/render3/instructions/listener.ts | 8 ++- packages/core/test/debug/debug_node_spec.ts | 59 ++++++++++++++++++- .../platform-browser/src/dom/dom_renderer.ts | 8 +++ .../platform-server/src/server_renderer.ts | 8 +++ 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/packages/core/src/debug/debug_node.ts b/packages/core/src/debug/debug_node.ts index 46b8d7f533..76919fd805 100644 --- a/packages/core/src/debug/debug_node.ts +++ b/packages/core/src/debug/debug_node.ts @@ -377,11 +377,28 @@ class DebugElement__POST_R3__ extends DebugNode__POST_R3__ implements DebugEleme } triggerEventHandler(eventName: string, eventObj: any): void { - this.listeners.forEach((listener) => { + const node = this.nativeNode as any; + const invokedListeners: Function[] = []; + + this.listeners.forEach(listener => { if (listener.name === eventName) { - listener.callback(eventObj); + const callback = listener.callback; + callback(eventObj); + invokedListeners.push(callback); } }); + + // We need to check whether `eventListeners` exists, because it's something + // that Zone.js only adds to `EventTarget` in browser environments. + if (typeof node.eventListeners === 'function') { + // Note that in Ivy we wrap event listeners with a call to `event.preventDefault` in some + // cases. We use `Function` as a special token that gives us access to the actual event + // listener. + node.eventListeners(eventName).forEach((listener: Function) => { + const unwrappedListener = listener(Function); + return invokedListeners.indexOf(unwrappedListener) === -1 && unwrappedListener(eventObj); + }); + } } } diff --git a/packages/core/src/render3/instructions/listener.ts b/packages/core/src/render3/instructions/listener.ts index 72ec368db6..6b58d46be3 100644 --- a/packages/core/src/render3/instructions/listener.ts +++ b/packages/core/src/render3/instructions/listener.ts @@ -235,7 +235,13 @@ function wrapListener( wrapWithPreventDefault: boolean): EventListener { // Note: we are performing most of the work in the listener function itself // to optimize listener registration. - return function wrapListenerIn_markDirtyAndPreventDefault(e: Event) { + return function wrapListenerIn_markDirtyAndPreventDefault(e: any) { + // Ivy uses `Function` as a special token that allows us to unwrap the function + // so that it can be invoked programmatically by `DebugNode.triggerEventHandler`. + if (e === Function) { + return listenerFn; + } + // In order to be backwards compatible with View Engine, events on component host nodes // must also mark the component view itself dirty (i.e. the view that it owns). const startView = diff --git a/packages/core/test/debug/debug_node_spec.ts b/packages/core/test/debug/debug_node_spec.ts index c01c8f1351..916787af03 100644 --- a/packages/core/test/debug/debug_node_spec.ts +++ b/packages/core/test/debug/debug_node_spec.ts @@ -8,11 +8,12 @@ import {CommonModule, NgIfContext} from '@angular/common'; -import {Component, DebugNode, Directive, ElementRef, EmbeddedViewRef, EventEmitter, HostBinding, Injectable, Input, NO_ERRORS_SCHEMA, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {Component, DebugNode, Directive, ElementRef, EmbeddedViewRef, EventEmitter, HostBinding, Injectable, Input, NO_ERRORS_SCHEMA, OnInit, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {expect} from '@angular/platform-browser/testing/src/matchers'; +import {ivyEnabled} from '@angular/private/testing'; @Injectable() class Logger { @@ -653,7 +654,6 @@ class TestCmptWithPropBindings { fixture.debugElement.children[1].triggerEventHandler('myevent', {}); expect(fixture.componentInstance.customed).toBe(true); - }); it('should include classes in properties.className', () => { @@ -683,6 +683,61 @@ class TestCmptWithPropBindings { expect(button.properties).toEqual({disabled: true, tabIndex: 1337, title: 'hello'}); }); + it('should trigger events registered via Renderer2', () => { + @Component({template: ''}) + class TestComponent implements OnInit { + count = 0; + eventObj: any; + constructor(private renderer: Renderer2, private elementRef: ElementRef) {} + + ngOnInit() { + this.renderer.listen(this.elementRef.nativeElement, 'click', (event: any) => { + this.count++; + this.eventObj = event; + }); + } + } + + TestBed.configureTestingModule({declarations: [TestComponent]}); + const fixture = TestBed.createComponent(TestComponent); + + // Ivy depends on `eventListeners` to pick up events that haven't been registered through + // Angular templates. At the time of writing Zone.js doesn't add `eventListeners` in Node + // environments so we have to skip the test. + if (!ivyEnabled || typeof fixture.debugElement.nativeElement.eventListeners === 'function') { + const event = {value: true}; + fixture.detectChanges(); + fixture.debugElement.triggerEventHandler('click', event); + expect(fixture.componentInstance.count).toBe(1); + expect(fixture.componentInstance.eventObj).toBe(event); + } + }); + + it('should be able to trigger an event with a null value', () => { + let value = undefined; + + @Component({template: ''}) + class TestComponent { + handleClick(_event: any) { + value = _event; + + // Returning `false` is what causes the renderer to call `event.preventDefault`. + return false; + } + } + + TestBed.configureTestingModule({declarations: [TestComponent]}); + const fixture = TestBed.createComponent(TestComponent); + const button = fixture.debugElement.query(By.css('button')); + + expect(() => { + button.triggerEventHandler('click', null); + fixture.detectChanges(); + }).not.toThrow(); + + expect(value).toBeNull(); + }); + describe('componentInstance on DebugNode', () => { it('should return component associated with a node if a node is a component host', () => { diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index 2ec54e612e..bda769027b 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -49,12 +49,20 @@ export function flattenStyles( function decoratePreventDefault(eventHandler: Function): Function { return (event: any) => { + // Ivy uses `Function` as a special token that allows us to unwrap the function + // so that it can be invoked programmatically by `DebugNode.triggerEventHandler`. + if (event === Function) { + return eventHandler; + } + const allowDefaultBehavior = eventHandler(event); if (allowDefaultBehavior === false) { // TODO(tbosch): move preventDefault into event plugins... event.preventDefault(); event.returnValue = false; } + + return undefined; }; } diff --git a/packages/platform-server/src/server_renderer.ts b/packages/platform-server/src/server_renderer.ts index e2c2c69dad..1229980975 100644 --- a/packages/platform-server/src/server_renderer.ts +++ b/packages/platform-server/src/server_renderer.ts @@ -182,6 +182,12 @@ class DefaultServerRenderer2 implements Renderer2 { private decoratePreventDefault(eventHandler: Function): Function { return (event: any) => { + // Ivy uses `Function` as a special token that allows us to unwrap the function + // so that it can be invoked programmatically by `DebugNode.triggerEventHandler`. + if (event === Function) { + return eventHandler; + } + // Run the event handler inside the ngZone because event handlers are not patched // by Zone on the server. This is required only for tests. const allowDefaultBehavior = this.ngZone.runGuarded(() => eventHandler(event)); @@ -189,6 +195,8 @@ class DefaultServerRenderer2 implements Renderer2 { event.preventDefault(); event.returnValue = false; } + + return undefined; }; } }