fix(ivy): adding event listeners for global objects (window, document, body) ()

This update introduces support for global object (window, document, body) listeners, that can be defined via host listeners on Components and Directives.

PR Close 
This commit is contained in:
Andrew Kushnir 2018-12-19 15:03:47 -08:00 committed by Kara Erickson
parent 917c09cfc8
commit 6e7c46af1b
15 changed files with 373 additions and 149 deletions

@ -588,11 +588,14 @@ describe('ngtsc behavioral tests', () => {
template: 'Test' template: 'Test'
}) })
class FooCmp { class FooCmp {
@HostListener('click')
onClick(event: any): void {}
@HostListener('document:click', ['$event.target']) @HostListener('document:click', ['$event.target'])
onClick(eventTarget: HTMLElement): void {} onDocumentClick(eventTarget: HTMLElement): void {}
@HostListener('window:scroll') @HostListener('window:scroll')
onScroll(event: any): void {} onWindowScroll(event: any): void {}
} }
`); `);
@ -601,14 +604,35 @@ describe('ngtsc behavioral tests', () => {
const hostBindingsFn = ` const hostBindingsFn = `
hostBindings: function FooCmp_HostBindings(rf, ctx, elIndex) { hostBindings: function FooCmp_HostBindings(rf, ctx, elIndex) {
if (rf & 1) { if (rf & 1) {
i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onClick($event.target); }); i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onClick(); });
i0.ɵlistener("scroll", function FooCmp_scroll_HostBindingHandler($event) { return ctx.onScroll(); }); i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onDocumentClick($event.target); }, false, i0.ɵresolveDocument);
i0.ɵlistener("scroll", function FooCmp_scroll_HostBindingHandler($event) { return ctx.onWindowScroll(); }, false, i0.ɵresolveWindow);
} }
} }
`; `;
expect(trim(jsContents)).toContain(trim(hostBindingsFn)); expect(trim(jsContents)).toContain(trim(hostBindingsFn));
}); });
it('should throw in case unknown global target is provided', () => {
env.tsconfig();
env.write(`test.ts`, `
import {Component, HostListener} from '@angular/core';
@Component({
selector: 'test',
template: 'Test'
})
class FooCmp {
@HostListener('UnknownTarget:click')
onClick(event: any): void {}
}
`);
const errors = env.driveDiagnostics();
expect(trim(errors[0].messageText as string))
.toContain(
`Unexpected global target 'UnknownTarget' defined for 'click' event. Supported list of global targets: window,document,body.`);
});
it('should generate host bindings for directives', () => { it('should generate host bindings for directives', () => {
env.tsconfig(); env.tsconfig();
env.write(`test.ts`, ` env.write(`test.ts`, `
@ -620,6 +644,7 @@ describe('ngtsc behavioral tests', () => {
host: { host: {
'[attr.hello]': 'foo', '[attr.hello]': 'foo',
'(click)': 'onClick($event)', '(click)': 'onClick($event)',
'(body:click)': 'onBodyClick($event)',
'[prop]': 'bar', '[prop]': 'bar',
}, },
}) })
@ -641,6 +666,7 @@ describe('ngtsc behavioral tests', () => {
if (rf & 1) { if (rf & 1) {
i0.ɵallocHostVars(2); i0.ɵallocHostVars(2);
i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onClick($event); }); i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onClick($event); });
i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onBodyClick($event); }, false, i0.ɵresolveBody);
i0.ɵlistener("change", function FooCmp_change_HostBindingHandler($event) { return ctx.onChange(ctx.arg1, ctx.arg2, ctx.arg3); }); i0.ɵlistener("change", function FooCmp_change_HostBindingHandler($event) { return ctx.onChange(ctx.arg1, ctx.arg2, ctx.arg3); });
i0.ɵelementStyling(_c0, null, null, ctx); i0.ɵelementStyling(_c0, null, null, ctx);
} }

@ -131,6 +131,10 @@ export class Identifiers {
static templateRefExtractor: static templateRefExtractor:
o.ExternalReference = {name: 'ɵtemplateRefExtractor', moduleName: CORE}; o.ExternalReference = {name: 'ɵtemplateRefExtractor', moduleName: CORE};
static resolveWindow: o.ExternalReference = {name: 'ɵresolveWindow', moduleName: CORE};
static resolveDocument: o.ExternalReference = {name: 'ɵresolveDocument', moduleName: CORE};
static resolveBody: o.ExternalReference = {name: 'ɵresolveBody', moduleName: CORE};
static defineBase: o.ExternalReference = {name: 'ɵdefineBase', moduleName: CORE}; static defineBase: o.ExternalReference = {name: 'ɵdefineBase', moduleName: CORE};
static BaseDef: o.ExternalReference = { static BaseDef: o.ExternalReference = {

@ -9,7 +9,7 @@
import {StaticSymbol} from '../../aot/static_symbol'; import {StaticSymbol} from '../../aot/static_symbol';
import {CompileDirectiveMetadata, CompileDirectiveSummary, CompileQueryMetadata, CompileTokenMetadata, identifierName, sanitizeIdentifier} from '../../compile_metadata'; import {CompileDirectiveMetadata, CompileDirectiveSummary, CompileQueryMetadata, CompileTokenMetadata, identifierName, sanitizeIdentifier} from '../../compile_metadata';
import {CompileReflector} from '../../compile_reflector'; import {CompileReflector} from '../../compile_reflector';
import {BindingForm, convertActionBinding, convertPropertyBinding} from '../../compiler_util/expression_converter'; import {BindingForm, convertPropertyBinding} from '../../compiler_util/expression_converter';
import {ConstantPool, DefinitionKind} from '../../constant_pool'; import {ConstantPool, DefinitionKind} from '../../constant_pool';
import * as core from '../../core'; import * as core from '../../core';
import {AST, ParsedEvent, ParsedEventType, ParsedProperty} from '../../expression_parser/ast'; import {AST, ParsedEvent, ParsedEventType, ParsedProperty} from '../../expression_parser/ast';
@ -22,14 +22,15 @@ import {ShadowCss} from '../../shadow_css';
import {CONTENT_ATTR, HOST_ATTR} from '../../style_compiler'; import {CONTENT_ATTR, HOST_ATTR} from '../../style_compiler';
import {BindingParser} from '../../template_parser/binding_parser'; import {BindingParser} from '../../template_parser/binding_parser';
import {OutputContext, error} from '../../util'; import {OutputContext, error} from '../../util';
import {BoundEvent} from '../r3_ast';
import {compileFactoryFunction, dependenciesFromGlobalMetadata} from '../r3_factory'; import {compileFactoryFunction, dependenciesFromGlobalMetadata} from '../r3_factory';
import {Identifiers as R3} from '../r3_identifiers'; import {Identifiers as R3} from '../r3_identifiers';
import {Render3ParseResult} from '../r3_template_transform'; import {Render3ParseResult} from '../r3_template_transform';
import {prepareSyntheticListenerFunctionName, prepareSyntheticListenerName, prepareSyntheticPropertyName, typeWithParameters} from '../util'; import {prepareSyntheticListenerFunctionName, prepareSyntheticPropertyName, typeWithParameters} from '../util';
import {R3ComponentDef, R3ComponentMetadata, R3DirectiveDef, R3DirectiveMetadata, R3QueryMetadata} from './api'; import {R3ComponentDef, R3ComponentMetadata, R3DirectiveDef, R3DirectiveMetadata, R3QueryMetadata} from './api';
import {StylingBuilder, StylingInstruction} from './styling_builder'; import {StylingBuilder, StylingInstruction} from './styling_builder';
import {BindingScope, TemplateDefinitionBuilder, ValueConverter, renderFlagCheckIfStmt} from './template'; import {BindingScope, TemplateDefinitionBuilder, ValueConverter, prepareEventListenerParameters, renderFlagCheckIfStmt} from './template';
import {CONTEXT_NAME, DefinitionMap, RENDER_FLAGS, TEMPORARY_NAME, asLiteral, conditionallyCreateMapObjectLiteral, getQueryPredicate, temporaryAllocator} from './util'; import {CONTEXT_NAME, DefinitionMap, RENDER_FLAGS, TEMPORARY_NAME, asLiteral, conditionallyCreateMapObjectLiteral, getQueryPredicate, temporaryAllocator} from './util';
const EMPTY_ARRAY: any[] = []; const EMPTY_ARRAY: any[] = [];
@ -809,21 +810,15 @@ function createHostListeners(
bindingContext: o.Expression, eventBindings: ParsedEvent[], bindingContext: o.Expression, eventBindings: ParsedEvent[],
meta: R3DirectiveMetadata): o.Statement[] { meta: R3DirectiveMetadata): o.Statement[] {
return eventBindings.map(binding => { return eventBindings.map(binding => {
const bindingExpr = convertActionBinding(
null, bindingContext, binding.handler, 'b', () => error('Unexpected interpolation'));
let bindingName = binding.name && sanitizeIdentifier(binding.name); let bindingName = binding.name && sanitizeIdentifier(binding.name);
let bindingFnName = bindingName; const bindingFnName = binding.type === ParsedEventType.Animation ?
if (binding.type === ParsedEventType.Animation) { prepareSyntheticListenerFunctionName(bindingName, binding.targetOrPhase) :
bindingFnName = prepareSyntheticListenerFunctionName(bindingName, binding.targetOrPhase); bindingName;
bindingName = prepareSyntheticListenerName(bindingName, binding.targetOrPhase); const handlerName =
} meta.name && bindingName ? `${meta.name}_${bindingFnName}_HostBindingHandler` : null;
const typeName = meta.name; const params = prepareEventListenerParameters(
const functionName = BoundEvent.fromParsedEvent(binding), bindingContext, handlerName);
typeName && bindingName ? `${typeName}_${bindingFnName}_HostBindingHandler` : null; return o.importExpr(R3.listener).callFn(params).toStmt();
const handler = o.fn(
[new o.FnParam('$event', o.DYNAMIC_TYPE)], [...bindingExpr.render3Stmts], o.INFERRED_TYPE,
null, functionName);
return o.importExpr(R3.listener).callFn([o.literal(bindingName), handler]).toStmt();
}); });
} }

@ -39,6 +39,16 @@ import {I18N_ICU_MAPPING_PREFIX, assembleBoundTextPlaceholders, assembleI18nBoun
import {StylingBuilder, StylingInstruction} from './styling_builder'; import {StylingBuilder, StylingInstruction} from './styling_builder';
import {CONTEXT_NAME, IMPLICIT_REFERENCE, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, asLiteral, getAttrsForDirectiveMatching, invalid, trimTrailingNulls, unsupported} from './util'; import {CONTEXT_NAME, IMPLICIT_REFERENCE, NON_BINDABLE_ATTR, REFERENCE_PREFIX, RENDER_FLAGS, asLiteral, getAttrsForDirectiveMatching, invalid, trimTrailingNulls, unsupported} from './util';
// Default selector used by `<ng-content>` if none specified
const DEFAULT_NG_CONTENT_SELECTOR = '*';
// Selector attribute name of `<ng-content>`
const NG_CONTENT_SELECT_ATTR = 'select';
// List of supported global targets for event listeners
const GLOBAL_TARGET_RESOLVERS = new Map<string, o.ExternalReference>(
[['window', R3.resolveWindow], ['document', R3.resolveDocument], ['body', R3.resolveBody]]);
function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefined { function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefined {
switch (type) { switch (type) {
case BindingType.Property: case BindingType.Property:
@ -59,11 +69,39 @@ export function renderFlagCheckIfStmt(
return o.ifStmt(o.variable(RENDER_FLAGS).bitwiseAnd(o.literal(flags), null, false), statements); return o.ifStmt(o.variable(RENDER_FLAGS).bitwiseAnd(o.literal(flags), null, false), statements);
} }
// Default selector used by `<ng-content>` if none specified export function prepareEventListenerParameters(
const DEFAULT_NG_CONTENT_SELECTOR = '*'; eventAst: t.BoundEvent, bindingContext: o.Expression, handlerName: string | null = null,
scope: BindingScope | null = null): o.Expression[] {
const {type, name, target, phase, handler} = eventAst;
if (target && !GLOBAL_TARGET_RESOLVERS.has(target)) {
throw new Error(`Unexpected global target '${target}' defined for '${name}' event.
Supported list of global targets: ${Array.from(GLOBAL_TARGET_RESOLVERS.keys())}.`);
}
// Selector attribute name of `<ng-content>` const bindingExpr = convertActionBinding(
const NG_CONTENT_SELECT_ATTR = 'select'; scope, bindingContext, handler, 'b', () => error('Unexpected interpolation'));
const statements = [];
if (scope) {
statements.push(...scope.restoreViewStatement());
statements.push(...scope.variableDeclarations());
}
statements.push(...bindingExpr.render3Stmts);
const eventName: string =
type === ParsedEventType.Animation ? prepareSyntheticListenerName(name, phase !) : name;
const fnName = handlerName && sanitizeIdentifier(handlerName);
const fnArgs = [new o.FnParam('$event', o.DYNAMIC_TYPE)];
const handlerFn = o.fn(fnArgs, statements, o.INFERRED_TYPE, null, fnName);
const params: o.Expression[] = [o.literal(eventName), handlerFn];
if (target) {
params.push(
o.literal(false), // `useCapture` flag, defaults to `false`
o.importExpr(GLOBAL_TARGET_RESOLVERS.get(target) !));
}
return params;
}
export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver { export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver {
private _dataIndex = 0; private _dataIndex = 0;
@ -1069,37 +1107,16 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
private prepareListenerParameter(tagName: string, outputAst: t.BoundEvent, index: number): private prepareListenerParameter(tagName: string, outputAst: t.BoundEvent, index: number):
() => o.Expression[] { () => o.Expression[] {
let eventName: string = outputAst.name;
let bindingFnName;
if (outputAst.type === ParsedEventType.Animation) {
// synthetic @listener.foo values are treated the exact same as are standard listeners
bindingFnName = prepareSyntheticListenerFunctionName(eventName, outputAst.phase !);
eventName = prepareSyntheticListenerName(eventName, outputAst.phase !);
} else {
bindingFnName = sanitizeIdentifier(eventName);
}
const tagNameSanitized = sanitizeIdentifier(tagName);
const functionName =
`${this.templateName}_${tagNameSanitized}_${bindingFnName}_${index}_listener`;
return () => { return () => {
const eventName: string = outputAst.name;
const listenerScope = this._bindingScope.nestedScope(this._bindingScope.bindingLevel); const bindingFnName = outputAst.type === ParsedEventType.Animation ?
// synthetic @listener.foo values are treated the exact same as are standard listeners
const bindingExpr = convertActionBinding( prepareSyntheticListenerFunctionName(eventName, outputAst.phase !) :
listenerScope, o.variable(CONTEXT_NAME), outputAst.handler, 'b', sanitizeIdentifier(eventName);
() => error('Unexpected interpolation')); const handlerName = `${this.templateName}_${tagName}_${bindingFnName}_${index}_listener`;
const scope = this._bindingScope.nestedScope(this._bindingScope.bindingLevel);
const statements = [ const context = o.variable(CONTEXT_NAME);
...listenerScope.restoreViewStatement(), ...listenerScope.variableDeclarations(), return prepareEventListenerParameters(outputAst, context, handlerName, scope);
...bindingExpr.render3Stmts
];
const handler = o.fn(
[new o.FnParam('$event', o.DYNAMIC_TYPE)], statements, o.INFERRED_TYPE, null,
functionName);
return [o.literal(eventName), handler];
}; };
} }
} }

@ -120,6 +120,9 @@ export {
i18nApply as ɵi18nApply, i18nApply as ɵi18nApply,
i18nPostprocess as ɵi18nPostprocess, i18nPostprocess as ɵi18nPostprocess,
setClassMetadata as ɵsetClassMetadata, setClassMetadata as ɵsetClassMetadata,
resolveWindow as ɵresolveWindow,
resolveDocument as ɵresolveDocument,
resolveBody as ɵresolveBody,
} from './render3/index'; } from './render3/index';

@ -144,6 +144,7 @@ export {
export {templateRefExtractor} from './view_engine_compatibility_prebound'; export {templateRefExtractor} from './view_engine_compatibility_prebound';
export {resolveWindow, resolveDocument, resolveBody} from './util';
// clang-format on // clang-format on

@ -29,7 +29,7 @@ import {AttributeMarker, InitialInputData, InitialInputs, LocalRefExtractor, Pro
import {PlayerFactory} from './interfaces/player'; import {PlayerFactory} from './interfaces/player';
import {CssSelectorList, NG_PROJECT_AS_ATTR_NAME} from './interfaces/projection'; import {CssSelectorList, NG_PROJECT_AS_ATTR_NAME} from './interfaces/projection';
import {LQueries} from './interfaces/query'; import {LQueries} from './interfaces/query';
import {ProceduralRenderer3, RComment, RElement, RText, Renderer3, RendererFactory3, isProceduralRenderer} from './interfaces/renderer'; import {GlobalTargetResolver, ProceduralRenderer3, RComment, RElement, RText, Renderer3, RendererFactory3, isProceduralRenderer} from './interfaces/renderer';
import {SanitizerFn} from './interfaces/sanitization'; import {SanitizerFn} from './interfaces/sanitization';
import {BINDING_INDEX, CLEANUP, CONTAINER_INDEX, CONTENT_QUERIES, CONTEXT, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, HOST_NODE, INJECTOR, LView, LViewFlags, NEXT, OpaqueViewState, PARENT, QUERIES, RENDERER, RENDERER_FACTORY, RootContext, RootContextFlags, SANITIZER, TAIL, TVIEW, TView} from './interfaces/view'; import {BINDING_INDEX, CLEANUP, CONTAINER_INDEX, CONTENT_QUERIES, CONTEXT, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, HOST_NODE, INJECTOR, LView, LViewFlags, NEXT, OpaqueViewState, PARENT, QUERIES, RENDERER, RENDERER_FACTORY, RootContext, RootContextFlags, SANITIZER, TAIL, TVIEW, TView} from './interfaces/view';
import {assertNodeOfPossibleTypes, assertNodeType} from './node_assert'; import {assertNodeOfPossibleTypes, assertNodeType} from './node_assert';
@ -822,10 +822,13 @@ export function locateHostElement(
* *
* @param eventName Name of the event * @param eventName Name of the event
* @param listenerFn 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
* @param eventTargetResolver Function that returns global target information in case this listener
* should be attached to a global object like window, document or body
*/ */
export function listener( export function listener(
eventName: string, listenerFn: (e?: any) => any, useCapture = false): void { eventName: string, listenerFn: (e?: any) => any, useCapture = false,
eventTargetResolver?: GlobalTargetResolver): void {
const lView = getLView(); const lView = getLView();
const tNode = getPreviousOrParentTNode(); const tNode = getPreviousOrParentTNode();
const tView = lView[TVIEW]; const tView = lView[TVIEW];
@ -837,6 +840,8 @@ export function listener(
// add native event listener - applicable to elements only // add native event listener - applicable to elements only
if (tNode.type === TNodeType.Element) { if (tNode.type === TNodeType.Element) {
const native = getNativeByTNode(tNode, lView) as RElement; const native = getNativeByTNode(tNode, lView) as RElement;
const resolved = eventTargetResolver ? eventTargetResolver(native) : {} as any;
const target = resolved.target || native;
ngDevMode && ngDevMode.rendererAddEventListener++; ngDevMode && ngDevMode.rendererAddEventListener++;
const renderer = lView[RENDERER]; const renderer = lView[RENDERER];
const lCleanup = getCleanup(lView); const lCleanup = getCleanup(lView);
@ -846,15 +851,22 @@ export function 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).
if (isProceduralRenderer(renderer)) { if (isProceduralRenderer(renderer)) {
const cleanupFn = renderer.listen(native, eventName, listenerFn); // The first argument of `listen` function in Procedural Renderer is:
// - either a target name (as a string) in case of global target (window, document, body)
// - or element reference (in all other cases)
const cleanupFn = renderer.listen(resolved.name || target, eventName, listenerFn);
lCleanup.push(listenerFn, cleanupFn); lCleanup.push(listenerFn, cleanupFn);
useCaptureOrSubIdx = lCleanupIndex + 1; useCaptureOrSubIdx = lCleanupIndex + 1;
} else { } else {
const wrappedListener = wrapListenerWithPreventDefault(listenerFn); const wrappedListener = wrapListenerWithPreventDefault(listenerFn);
native.addEventListener(eventName, wrappedListener, useCapture); target.addEventListener(eventName, wrappedListener, useCapture);
lCleanup.push(wrappedListener); lCleanup.push(wrappedListener);
} }
tCleanup && tCleanup.push(eventName, tNode.index, lCleanupIndex, useCaptureOrSubIdx);
const idxOrTargetGetter = eventTargetResolver ?
(_lView: LView) => eventTargetResolver(readElementValue(_lView[tNode.index])).target :
tNode.index;
tCleanup && tCleanup.push(eventName, idxOrTargetGetter, lCleanupIndex, useCaptureOrSubIdx);
} }
// subscribe to directive outputs // subscribe to directive outputs

@ -26,6 +26,12 @@ export enum RendererStyleFlags3 {
export type Renderer3 = ObjectOrientedRenderer3 | ProceduralRenderer3; export type Renderer3 = ObjectOrientedRenderer3 | ProceduralRenderer3;
export type GlobalTargetName = 'document' | 'window' | 'body';
export type GlobalTargetResolver = (element: any) => {
name: GlobalTargetName, target: EventTarget
};
/** /**
* Object Oriented style of API needed to create elements and text nodes. * Object Oriented style of API needed to create elements and text nodes.
* *
@ -86,7 +92,9 @@ export interface ProceduralRenderer3 {
setValue(node: RText|RComment, value: string): void; setValue(node: RText|RComment, value: string): void;
// TODO(misko): Deprecate in favor of addEventListener/removeEventListener // TODO(misko): Deprecate in favor of addEventListener/removeEventListener
listen(target: RNode, eventName: string, callback: (event: any) => boolean | void): () => void; listen(
target: GlobalTargetName|RNode, eventName: string,
callback: (event: any) => boolean | void): () => void;
} }
export interface RendererFactory3 { export interface RendererFactory3 {

@ -446,7 +446,11 @@ export interface TView {
* *
* If it's a native DOM listener or output subscription being stored: * If it's a native DOM listener or output subscription being stored:
* 1st index is: event name `name = tView.cleanup[i+0]` * 1st index is: event name `name = tView.cleanup[i+0]`
* 2nd index is: index of native element `element = lView[tView.cleanup[i+1]]` * 2nd index is: index of native element or a function that retrieves global target (window,
* document or body) reference based on the native element:
* `typeof idxOrTargetGetter === 'function'`: global target getter function
* `typeof idxOrTargetGetter === 'number'`: index of native element
*
* 3rd index is: index of listener function `listener = lView[CLEANUP][tView.cleanup[i+2]]` * 3rd index is: index of listener function `listener = lView[CLEANUP][tView.cleanup[i+2]]`
* 4th index is: `useCaptureOrIndx = tView.cleanup[i+3]` * 4th index is: `useCaptureOrIndx = tView.cleanup[i+3]`
* `typeof useCaptureOrIndx == 'boolean' : useCapture boolean * `typeof useCaptureOrIndx == 'boolean' : useCapture boolean

@ -107,6 +107,9 @@ export const angularCoreEnv: {[name: string]: Function} = {
'ɵi18nEnd': r3.i18nEnd, 'ɵi18nEnd': r3.i18nEnd,
'ɵi18nApply': r3.i18nApply, 'ɵi18nApply': r3.i18nApply,
'ɵi18nPostprocess': r3.i18nPostprocess, 'ɵi18nPostprocess': r3.i18nPostprocess,
'ɵresolveWindow': r3.resolveWindow,
'ɵresolveDocument': r3.resolveDocument,
'ɵresolveBody': r3.resolveBody,
'ɵsanitizeHtml': sanitization.sanitizeHtml, 'ɵsanitizeHtml': sanitization.sanitizeHtml,
'ɵsanitizeStyle': sanitization.sanitizeStyle, 'ɵsanitizeStyle': sanitization.sanitizeStyle,

@ -445,13 +445,15 @@ function removeListeners(lView: LView): void {
for (let i = 0; i < tCleanup.length - 1; i += 2) { for (let i = 0; i < tCleanup.length - 1; i += 2) {
if (typeof tCleanup[i] === 'string') { if (typeof tCleanup[i] === 'string') {
// This is a listener with the native renderer // This is a listener with the native renderer
const idx = tCleanup[i + 1]; const idxOrTargetGetter = tCleanup[i + 1];
const target = typeof idxOrTargetGetter === 'function' ?
idxOrTargetGetter(lView) :
readElementValue(lView[idxOrTargetGetter]);
const listener = lCleanup[tCleanup[i + 2]]; const listener = lCleanup[tCleanup[i + 2]];
const native = readElementValue(lView[idx]);
const useCaptureOrSubIdx = tCleanup[i + 3]; const useCaptureOrSubIdx = tCleanup[i + 3];
if (typeof useCaptureOrSubIdx === 'boolean') { if (typeof useCaptureOrSubIdx === 'boolean') {
// DOM listener // DOM listener
native.removeEventListener(tCleanup[i], listener, useCaptureOrSubIdx); target.removeEventListener(tCleanup[i], listener, useCaptureOrSubIdx);
} else { } else {
if (useCaptureOrSubIdx >= 0) { if (useCaptureOrSubIdx >= 0) {
// unregister // unregister

@ -14,7 +14,7 @@ import {LContext, MONKEY_PATCH_KEY_NAME} from './interfaces/context';
import {ComponentDef, DirectiveDef} from './interfaces/definition'; import {ComponentDef, DirectiveDef} from './interfaces/definition';
import {NO_PARENT_INJECTOR, RelativeInjectorLocation, RelativeInjectorLocationFlags} from './interfaces/injector'; import {NO_PARENT_INJECTOR, RelativeInjectorLocation, RelativeInjectorLocationFlags} from './interfaces/injector';
import {TContainerNode, TElementNode, TNode, TNodeFlags, TNodeType} from './interfaces/node'; import {TContainerNode, TElementNode, TNode, TNodeFlags, TNodeType} from './interfaces/node';
import {RComment, RElement, RText} from './interfaces/renderer'; import {GlobalTargetName, GlobalTargetResolver, RComment, RElement, RText} from './interfaces/renderer';
import {StylingContext} from './interfaces/styling'; import {StylingContext} from './interfaces/styling';
import {CONTEXT, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, HOST_NODE, LView, LViewFlags, PARENT, RootContext, TData, TVIEW, TView} from './interfaces/view'; import {CONTEXT, DECLARATION_VIEW, FLAGS, HEADER_OFFSET, HOST, HOST_NODE, LView, LViewFlags, PARENT, RootContext, TData, TVIEW, TView} from './interfaces/view';
@ -276,3 +276,15 @@ export function findComponentView(lView: LView): LView {
return lView; return lView;
} }
export function resolveWindow(element: RElement & {ownerDocument: Document}) {
return {name: 'window', target: element.ownerDocument.defaultView};
}
export function resolveDocument(element: RElement & {ownerDocument: Document}) {
return {name: 'document', target: element.ownerDocument};
}
export function resolveBody(element: RElement & {ownerDocument: Document}) {
return {name: 'body', target: element.ownerDocument.body};
}

@ -845,9 +845,7 @@ function declareTests(config?: {useJit: boolean}) {
dir.triggerChange('two'); dir.triggerChange('two');
})); }));
fixmeIvy( it('should support render events', () => {
'FW-743: Registering events on global objects (document, window, body) is not supported')
.it('should support render events', () => {
TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]}); TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]});
const template = '<div listener></div>'; const template = '<div listener></div>';
TestBed.overrideComponent(MyComp, {set: {template}}); TestBed.overrideComponent(MyComp, {set: {template}});
@ -868,9 +866,7 @@ function declareTests(config?: {useJit: boolean}) {
expect(listener.eventTypes).toEqual([]); expect(listener.eventTypes).toEqual([]);
}); });
fixmeIvy( it('should support render global events', () => {
'FW-743: Registering events on global objects (document, window, body) is not supported')
.it('should support render global events', () => {
TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]}); TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]});
const template = '<div listener></div>'; const template = '<div listener></div>';
TestBed.overrideComponent(MyComp, {set: {template}}); TestBed.overrideComponent(MyComp, {set: {template}});
@ -1027,12 +1023,9 @@ function declareTests(config?: {useJit: boolean}) {
}); });
} }
fixmeIvy( it('should support render global events from multiple directives', () => {
'FW-743: Registering events on global objects (document, window, body) is not supported') TestBed.configureTestingModule(
.it('should support render global events from multiple directives', () => { {declarations: [MyComp, DirectiveListeningDomEvent, DirectiveListeningDomEventOther]});
TestBed.configureTestingModule({
declarations: [MyComp, DirectiveListeningDomEvent, DirectiveListeningDomEventOther]
});
const template = '<div *ngIf="ctxBoolProp" listener listenerother></div>'; const template = '<div *ngIf="ctxBoolProp" listener listenerother></div>';
TestBed.overrideComponent(MyComp, {set: {template}}); TestBed.overrideComponent(MyComp, {set: {template}});
const fixture = TestBed.createComponent(MyComp); const fixture = TestBed.createComponent(MyComp);

@ -6,17 +6,21 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {bind, defineComponent, defineDirective, markDirty, reference, textBinding} from '../../src/render3/index'; import {dispatchEvent} from '@angular/platform-browser/testing/src/browser_util';
import {bind, defineComponent, defineDirective, markDirty, reference, resolveBody, resolveDocument, textBinding} from '../../src/render3/index';
import {container, containerRefreshEnd, containerRefreshStart, element, elementEnd, elementStart, embeddedViewEnd, embeddedViewStart, getCurrentView, listener, text} from '../../src/render3/instructions'; import {container, containerRefreshEnd, containerRefreshStart, element, elementEnd, elementStart, embeddedViewEnd, embeddedViewStart, getCurrentView, listener, text} from '../../src/render3/instructions';
import {RenderFlags} from '../../src/render3/interfaces/definition'; import {RenderFlags} from '../../src/render3/interfaces/definition';
import {GlobalTargetResolver} from '../../src/render3/interfaces/renderer';
import {restoreView} from '../../src/render3/state'; import {restoreView} from '../../src/render3/state';
import {getRendererFactory2} from './imported_renderer2'; import {getRendererFactory2} from './imported_renderer2';
import {ComponentFixture, containerEl, createComponent, getDirectiveOnNode, renderToHtml, requestAnimationFrame} from './render_util'; import {ComponentFixture, TemplateFixture, containerEl, createComponent, getDirectiveOnNode, renderToHtml, requestAnimationFrame} from './render_util';
describe('event listeners', () => { describe('event listeners', () => {
let comps: MyComp[] = []; let comps: any[] = [];
let events: any[] = [];
class MyComp { class MyComp {
showing = true; showing = true;
@ -48,6 +52,67 @@ describe('event listeners', () => {
}); });
} }
class MyCompWithGlobalListeners {
/* @HostListener('document:custom') */
onDocumentCustomEvent() { events.push('component - document:custom'); }
/* @HostListener('body:click') */
onBodyClick() { events.push('component - body:click'); }
static ngComponentDef = defineComponent({
type: MyCompWithGlobalListeners,
selectors: [['comp']],
consts: 1,
vars: 0,
template: function CompTemplate(rf: RenderFlags, ctx: any) {
if (rf & RenderFlags.Create) {
text(0, 'Some text');
}
},
factory: () => {
let comp = new MyCompWithGlobalListeners();
comps.push(comp);
return comp;
},
hostBindings: function HostListenerDir_HostBindings(
rf: RenderFlags, ctx: any, elIndex: number) {
if (rf & RenderFlags.Create) {
listener('custom', function() {
return ctx.onDocumentCustomEvent();
}, false, resolveDocument as GlobalTargetResolver);
listener('click', function() {
return ctx.onBodyClick();
}, false, resolveBody as GlobalTargetResolver);
}
}
});
}
class GlobalHostListenerDir {
/* @HostListener('document:custom') */
onDocumentCustomEvent() { events.push('directive - document:custom'); }
/* @HostListener('body:click') */
onBodyClick() { events.push('directive - body:click'); }
static ngDirectiveDef = defineDirective({
type: GlobalHostListenerDir,
selectors: [['', 'hostListenerDir', '']],
factory: function HostListenerDir_Factory() { return new GlobalHostListenerDir(); },
hostBindings: function HostListenerDir_HostBindings(
rf: RenderFlags, ctx: any, elIndex: number) {
if (rf & RenderFlags.Create) {
listener('custom', function() {
return ctx.onDocumentCustomEvent();
}, false, resolveDocument as GlobalTargetResolver);
listener('click', function() {
return ctx.onBodyClick();
}, false, resolveBody as GlobalTargetResolver);
}
}
});
}
class PreventDefaultComp { class PreventDefaultComp {
handlerReturnValue: any = true; handlerReturnValue: any = true;
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
@ -84,7 +149,10 @@ describe('event listeners', () => {
}); });
} }
beforeEach(() => { comps = []; }); beforeEach(() => {
comps = [];
events = [];
});
it('should call function on event emit', () => { it('should call function on event emit', () => {
const fixture = new ComponentFixture(MyComp); const fixture = new ComponentFixture(MyComp);
@ -477,6 +545,7 @@ describe('event listeners', () => {
const fixture = new ComponentFixture(MyComp); const fixture = new ComponentFixture(MyComp);
const host = fixture.hostElement; const host = fixture.hostElement;
host.click(); host.click();
expect(events).toEqual(['click!']); expect(events).toEqual(['click!']);
@ -484,6 +553,20 @@ describe('event listeners', () => {
expect(events).toEqual(['click!', 'click!']); expect(events).toEqual(['click!', 'click!']);
}); });
it('should support global host listeners on components', () => {
const fixture = new ComponentFixture(MyCompWithGlobalListeners);
const doc = fixture.hostElement.ownerDocument !;
dispatchEvent(doc, 'custom');
expect(events).toEqual(['component - document:custom']);
dispatchEvent(doc.body, 'click');
expect(events).toEqual(['component - document:custom', 'component - body:click']);
// invoke destroy for this fixture to cleanup all listeners setup for global objects
fixture.destroy();
});
it('should support host listeners on directives', () => { it('should support host listeners on directives', () => {
let events: string[] = []; let events: string[] = [];
@ -504,16 +587,14 @@ describe('event listeners', () => {
}); });
} }
function Template(rf: RenderFlags, ctx: any) { const fixture = new TemplateFixture(() => {
if (rf & RenderFlags.Create) {
elementStart(0, 'button', ['hostListenerDir', '']); elementStart(0, 'button', ['hostListenerDir', '']);
text(1, 'Click'); text(1, 'Click');
elementEnd(); elementEnd();
} }, () => {}, 2, 0, [HostListenerDir]);
}
const button = fixture.hostElement.querySelector('button') !;
renderToHtml(Template, {}, 2, 0, [HostListenerDir]);
const button = containerEl.querySelector('button') !;
button.click(); button.click();
expect(events).toEqual(['click!']); expect(events).toEqual(['click!']);
@ -521,6 +602,23 @@ describe('event listeners', () => {
expect(events).toEqual(['click!', 'click!']); expect(events).toEqual(['click!', 'click!']);
}); });
it('should support global host listeners on directives', () => {
const fixture = new TemplateFixture(() => {
element(0, 'div', ['hostListenerDir', '']);
}, () => {}, 1, 0, [GlobalHostListenerDir]);
const doc = fixture.hostElement.ownerDocument !;
dispatchEvent(doc, 'custom');
expect(events).toEqual(['directive - document:custom']);
dispatchEvent(doc.body, 'click');
expect(events).toEqual(['directive - document:custom', 'directive - body:click']);
// invoke destroy for this fixture to cleanup all listeners setup for global objects
fixture.destroy();
});
it('should support listeners with specified set of args', () => { it('should support listeners with specified set of args', () => {
class MyComp { class MyComp {
counter = 0; counter = 0;
@ -673,6 +771,40 @@ describe('event listeners', () => {
expect(comps[1] !.counter).toEqual(1); expect(comps[1] !.counter).toEqual(1);
}); });
it('should destroy global listeners in component views', () => {
const ctx = {showing: true};
const fixture = new TemplateFixture(
() => { container(0); },
() => {
containerRefreshStart(0);
{
if (ctx.showing) {
let rf1 = embeddedViewStart(0, 1, 0);
if (rf1 & RenderFlags.Create) {
element(0, 'comp');
}
embeddedViewEnd();
}
}
containerRefreshEnd();
},
1, 0, [MyCompWithGlobalListeners]);
const body = fixture.hostElement.ownerDocument !.body;
body.click();
expect(events).toEqual(['component - body:click']);
// the child view listener should be removed when the parent view is removed
ctx.showing = false;
fixture.update();
body.click();
// expecting no changes in events array
expect(events).toEqual(['component - body:click']);
});
it('should support listeners with sibling nested containers', () => { it('should support listeners with sibling nested containers', () => {
/** /**
* % if (condition) { * % if (condition) {

@ -31,6 +31,8 @@ import {DirectiveDefList, DirectiveTypesOrFactory, PipeDef, PipeDefList, PipeTyp
import {PlayerHandler} from '../../src/render3/interfaces/player'; import {PlayerHandler} from '../../src/render3/interfaces/player';
import {ProceduralRenderer3, RComment, RElement, RNode, RText, Renderer3, RendererFactory3, RendererStyleFlags3, domRendererFactory3} from '../../src/render3/interfaces/renderer'; import {ProceduralRenderer3, RComment, RElement, RNode, RText, Renderer3, RendererFactory3, RendererStyleFlags3, domRendererFactory3} from '../../src/render3/interfaces/renderer';
import {HEADER_OFFSET, LView} from '../../src/render3/interfaces/view'; import {HEADER_OFFSET, LView} from '../../src/render3/interfaces/view';
import {destroyLView} from '../../src/render3/node_manipulation';
import {getRootView} from '../../src/render3/util';
import {Sanitizer} from '../../src/sanitization/security'; import {Sanitizer} from '../../src/sanitization/security';
import {Type} from '../../src/type'; import {Type} from '../../src/type';
@ -130,6 +132,11 @@ export class TemplateFixture extends BaseFixture {
this.hostElement, updateBlock || this.updateBlock, 0, this.vars, null !, this.hostElement, updateBlock || this.updateBlock, 0, this.vars, null !,
this._rendererFactory, this.hostView, this._directiveDefs, this._pipeDefs, this._sanitizer); this._rendererFactory, this.hostView, this._directiveDefs, this._pipeDefs, this._sanitizer);
} }
destroy(): void {
this.containerElement.removeChild(this.hostElement);
destroyLView(this.hostView);
}
} }
@ -171,6 +178,11 @@ export class ComponentFixture<T> extends BaseFixture {
tick(this.component); tick(this.component);
this.requestAnimationFrame.flush(); this.requestAnimationFrame.flush();
} }
destroy(): void {
this.containerElement.removeChild(this.hostElement);
destroyLView(getRootView(this.component));
}
} }
/////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////