fix(ivy): preventDefault when listener returns false (#22529)

Closes #22495

PR Close #22529
This commit is contained in:
Kara Erickson 2018-03-01 09:46:39 -08:00 committed by Alex Eagle
parent 58932c7f38
commit 2c2b62f45f
4 changed files with 151 additions and 28 deletions

View File

@ -619,22 +619,24 @@ export function hostElement(rNode: RElement | null, def: ComponentDef<any>): LEl
* and saves the subscription for later cleanup. * and saves the subscription for later cleanup.
* *
* @param eventName Name of the event * @param eventName Name of the event
* @param listener The function to be called when event emits * @param listenerFn The function to be called when event emits
* @param useCapture Whether or not to use capture in event listener. * @param useCapture Whether or not to use capture in event listener.
*/ */
export function listener(eventName: string, listener: EventListener, useCapture = false): void { export function listener(
eventName: string, listenerFn: (e?: any) => any, useCapture = false): void {
ngDevMode && assertPreviousIsParent(); ngDevMode && assertPreviousIsParent();
const node = previousOrParentNode; const node = previousOrParentNode;
const native = node.native as RElement; const native = node.native as RElement;
const wrappedListener = wrapListenerWithDirtyLogic(currentView, listener);
// In order to match current behavior, native DOM event listeners must be added for all // In order to match current behavior, native DOM event listeners must be added for all
// events (including outputs). // events (including outputs).
const cleanupFns = cleanup || (cleanup = currentView.cleanup = []); const cleanupFns = cleanup || (cleanup = currentView.cleanup = []);
if (isProceduralRenderer(renderer)) { if (isProceduralRenderer(renderer)) {
const wrappedListener = wrapListenerWithDirtyLogic(currentView, listenerFn);
const cleanupFn = renderer.listen(native, eventName, wrappedListener); const cleanupFn = renderer.listen(native, eventName, wrappedListener);
cleanupFns.push(cleanupFn, null); cleanupFns.push(cleanupFn, null);
} else { } else {
const wrappedListener = wrapListenerWithDirtyAndDefault(currentView, listenerFn);
native.addEventListener(eventName, wrappedListener, useCapture); native.addEventListener(eventName, wrappedListener, useCapture);
cleanupFns.push(eventName, native, wrappedListener, useCapture); cleanupFns.push(eventName, native, wrappedListener, useCapture);
} }
@ -649,7 +651,7 @@ export function listener(eventName: string, listener: EventListener, useCapture
const outputs = tNode.outputs; const outputs = tNode.outputs;
let outputData: PropertyAliasValue|undefined; let outputData: PropertyAliasValue|undefined;
if (outputs && (outputData = outputs[eventName])) { if (outputs && (outputData = outputs[eventName])) {
createOutput(outputData, listener); createOutput(outputData, listenerFn);
} }
} }
@ -1437,10 +1439,27 @@ export function markDirtyIfOnPush(node: LElementNode): void {
* Wraps an event listener so its host view and its ancestor views will be marked dirty * Wraps an event listener so its host view and its ancestor views will be marked dirty
* whenever the event fires. Necessary to support OnPush components. * whenever the event fires. Necessary to support OnPush components.
*/ */
export function wrapListenerWithDirtyLogic(view: LView, listener: EventListener): EventListener { export function wrapListenerWithDirtyLogic(view: LView, listenerFn: (e?: any) => any): (e: Event) =>
any {
return function(e: any) {
markViewDirty(view);
return listenerFn(e);
};
}
/**
* Wraps an event listener so its host view and its ancestor views will be marked dirty
* whenever the event fires. Also wraps with preventDefault behavior.
*/
export function wrapListenerWithDirtyAndDefault(
view: LView, listenerFn: (e?: any) => any): EventListener {
return function(e: Event) { return function(e: Event) {
markViewDirty(view); markViewDirty(view);
listener(e); if (listenerFn(e) === false) {
e.preventDefault();
// Necessary for legacy browsers that don't support preventDefault (e.g. IE)
e.returnValue = false;
}
}; };
} }

View File

@ -51,4 +51,42 @@ describe('elements', () => {
expect(toHtml(renderComponent(MyComponent))) expect(toHtml(renderComponent(MyComponent)))
.toEqual('<div class="my-app" title="Hello">Hello <b>World</b>!</div>'); .toEqual('<div class="my-app" title="Hello">Hello <b>World</b>!</div>');
}); });
it('should support listeners', () => {
type $ListenerComp$ = ListenerComp;
@Component({
selector: 'listener-comp',
template:
`<button (click)="onClick()" (keypress)="onPress($event); onPress2($event)">Click</button>`
})
class ListenerComp {
onClick() {}
onPress(e: Event) {}
onPress2(e: Event) {}
// NORMATIVE
static ngComponentDef = $r3$.ɵdefineComponent({
type: ListenerComp,
tag: 'listener-comp',
factory: function ListenerComp_Factory() { return new ListenerComp(); },
template: function ListenerComp_Template(ctx: $ListenerComp$, cm: $boolean$) {
if (cm) {
$r3$.ɵE(0, 'button');
$r3$.ɵL('click', function ListenerComp_click_Handler() { return ctx.onClick(); });
$r3$.ɵL('keypress', function ListenerComp_keypress_Handler($event: $any$) {
ctx.onPress($event);
return ctx.onPress2($event);
});
$r3$.ɵT(1, 'Click');
$r3$.ɵe();
}
}
});
// /NORMATIVE
}
const listenerComp = renderComponent(ListenerComp);
expect(toHtml(listenerComp)).toEqual('<button>Click</button>');
});
}); });

View File

@ -9,6 +9,7 @@
import {defineComponent, defineDirective} from '../../src/render3/index'; import {defineComponent, defineDirective} from '../../src/render3/index';
import {container, containerRefreshEnd, containerRefreshStart, directiveRefresh, elementEnd, elementStart, embeddedViewEnd, embeddedViewStart, listener, text} from '../../src/render3/instructions'; import {container, containerRefreshEnd, containerRefreshStart, directiveRefresh, elementEnd, elementStart, embeddedViewEnd, embeddedViewStart, listener, text} from '../../src/render3/instructions';
import {getRendererFactory2} from './imported_renderer2';
import {containerEl, renderComponent, renderToHtml} from './render_util'; import {containerEl, renderComponent, renderToHtml} from './render_util';
@ -29,7 +30,7 @@ describe('event listeners', () => {
if (cm) { if (cm) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.onClick(); }); listener('click', function() { return ctx.onClick(); });
text(1, 'Click me'); text(1, 'Click me');
} }
elementEnd(); elementEnd();
@ -43,6 +44,39 @@ describe('event listeners', () => {
}); });
} }
class PreventDefaultComp {
handlerReturnValue: any = true;
event: Event;
onClick(e: any) {
this.event = e;
// stub preventDefault() to check whether it's called
Object.defineProperty(
this.event, 'preventDefault',
{value: jasmine.createSpy('preventDefault'), writable: true});
return this.handlerReturnValue;
}
static ngComponentDef = defineComponent({
type: PreventDefaultComp,
tag: 'prevent-default-comp',
factory: () => new PreventDefaultComp(),
/** <button (click)="onClick($event)">Click</button> */
template: (ctx: PreventDefaultComp, cm: boolean) => {
if (cm) {
elementStart(0, 'button');
{
listener('click', function($event: any) { return ctx.onClick($event); });
text(1, 'Click');
}
elementEnd();
}
}
});
}
beforeEach(() => { comps = []; }); beforeEach(() => { comps = []; });
it('should call function on event emit', () => { it('should call function on event emit', () => {
@ -55,6 +89,38 @@ describe('event listeners', () => {
expect(comp.counter).toEqual(2); expect(comp.counter).toEqual(2);
}); });
it('should retain event handler return values using document', () => {
const preventDefaultComp = renderComponent(PreventDefaultComp);
const button = containerEl.querySelector('button') !;
button.click();
expect(preventDefaultComp.event !.preventDefault).not.toHaveBeenCalled();
preventDefaultComp.handlerReturnValue = undefined;
button.click();
expect(preventDefaultComp.event !.preventDefault).not.toHaveBeenCalled();
preventDefaultComp.handlerReturnValue = false;
button.click();
expect(preventDefaultComp.event !.preventDefault).toHaveBeenCalled();
});
it('should retain event handler return values with renderer2', () => {
const preventDefaultComp = renderComponent(PreventDefaultComp, getRendererFactory2(document));
const button = containerEl.querySelector('button') !;
button.click();
expect(preventDefaultComp.event !.preventDefault).not.toHaveBeenCalled();
preventDefaultComp.handlerReturnValue = undefined;
button.click();
expect(preventDefaultComp.event !.preventDefault).not.toHaveBeenCalled();
preventDefaultComp.handlerReturnValue = false;
button.click();
expect(preventDefaultComp.event !.preventDefault).toHaveBeenCalled();
});
it('should call function chain on event emit', () => { it('should call function chain on event emit', () => {
/** <button (click)="onClick(); onClick2(); "> Click me </button> */ /** <button (click)="onClick(); onClick2(); "> Click me </button> */
function Template(ctx: any, cm: boolean) { function Template(ctx: any, cm: boolean) {
@ -63,7 +129,7 @@ describe('event listeners', () => {
{ {
listener('click', function() { listener('click', function() {
ctx.onClick(); ctx.onClick();
ctx.onClick2(); return ctx.onClick2();
}); });
text(1, 'Click me'); text(1, 'Click me');
} }
@ -96,7 +162,7 @@ describe('event listeners', () => {
if (cm) { if (cm) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.showing = !ctx.showing; }); listener('click', function() { return ctx.showing = !ctx.showing; });
text(1, 'Click me'); text(1, 'Click me');
} }
elementEnd(); elementEnd();
@ -131,7 +197,7 @@ describe('event listeners', () => {
if (embeddedViewStart(1)) { if (embeddedViewStart(1)) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.onClick(); }); listener('click', function() { return ctx.onClick(); });
text(1, 'Click me'); text(1, 'Click me');
} }
elementEnd(); elementEnd();
@ -170,7 +236,7 @@ describe('event listeners', () => {
type: HostListenerDir, type: HostListenerDir,
factory: function HostListenerDir_Factory() { factory: function HostListenerDir_Factory() {
const $dir$ = new HostListenerDir(); const $dir$ = new HostListenerDir();
listener('click', function() { $dir$.onClick(); }); listener('click', function() { return $dir$.onClick(); });
return $dir$; return $dir$;
}, },
}); });
@ -222,7 +288,7 @@ describe('event listeners', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.onClick(); }); listener('click', function() { return ctx.onClick(); });
text(1, 'Click'); text(1, 'Click');
} }
elementEnd(); elementEnd();
@ -337,7 +403,7 @@ describe('event listeners', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.counter1++; }); listener('click', function() { return ctx.counter1++; });
text(1, 'Click'); text(1, 'Click');
} }
elementEnd(); elementEnd();
@ -352,7 +418,7 @@ describe('event listeners', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.counter2++; }); listener('click', function() { return ctx.counter2++; });
text(1, 'Click'); text(1, 'Click');
} }
elementEnd(); elementEnd();

View File

@ -47,7 +47,7 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, ButtonToggle); elementStart(0, ButtonToggle);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }
@ -72,8 +72,8 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, ButtonToggle); elementStart(0, ButtonToggle);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
listener('reset', function() { ctx.onReset(); }); listener('reset', function() { return ctx.onReset(); });
} }
elementEnd(); elementEnd();
} }
@ -99,7 +99,7 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, ButtonToggle); elementStart(0, ButtonToggle);
{ {
listener('change', function() { ctx.counter++; }); listener('change', function() { return ctx.counter++; });
} }
elementEnd(); elementEnd();
} }
@ -135,7 +135,7 @@ describe('outputs', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, ButtonToggle); elementStart(0, ButtonToggle);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }
@ -187,7 +187,7 @@ describe('outputs', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, ButtonToggle); elementStart(0, ButtonToggle);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }
@ -249,13 +249,13 @@ describe('outputs', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.onClick(); }); listener('click', function() { return ctx.onClick(); });
text(1, 'Click me'); text(1, 'Click me');
} }
elementEnd(); elementEnd();
elementStart(2, ButtonToggle); elementStart(2, ButtonToggle);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
elementStart(4, DestroyComp); elementStart(4, DestroyComp);
@ -311,7 +311,7 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, 'button', null, [MyButton]); elementStart(0, 'button', null, [MyButton]);
{ {
listener('click', function() { ctx.onClick(); }); listener('click', function() { return ctx.onClick(); });
} }
elementEnd(); elementEnd();
} }
@ -336,7 +336,7 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, ButtonToggle, null, [OtherDir]); elementStart(0, ButtonToggle, null, [OtherDir]);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }
@ -369,7 +369,7 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, ButtonToggle, null, [OtherDir]); elementStart(0, ButtonToggle, null, [OtherDir]);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }
@ -403,7 +403,7 @@ describe('outputs', () => {
if (cm) { if (cm) {
elementStart(0, 'button'); elementStart(0, 'button');
{ {
listener('click', function() { ctx.onClick(); }); listener('click', function() { return ctx.onClick(); });
text(1, 'Click me'); text(1, 'Click me');
} }
elementEnd(); elementEnd();
@ -415,7 +415,7 @@ describe('outputs', () => {
if (embeddedViewStart(0)) { if (embeddedViewStart(0)) {
elementStart(0, ButtonToggle); elementStart(0, ButtonToggle);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }
@ -426,7 +426,7 @@ describe('outputs', () => {
if (embeddedViewStart(1)) { if (embeddedViewStart(1)) {
elementStart(0, 'div', null, [OtherDir]); elementStart(0, 'div', null, [OtherDir]);
{ {
listener('change', function() { ctx.onChange(); }); listener('change', function() { return ctx.onChange(); });
} }
elementEnd(); elementEnd();
} }