From 6e7c46af1bf6723ee558421a66eec3533baf65b8 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 19 Dec 2018 15:03:47 -0800 Subject: [PATCH] fix(ivy): adding event listeners for global objects (window, document, body) (#27772) This update introduces support for global object (window, document, body) listeners, that can be defined via host listeners on Components and Directives. PR Close #27772 --- .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 34 +++- .../compiler/src/render3/r3_identifiers.ts | 4 + .../compiler/src/render3/view/compiler.ts | 29 ++-- .../compiler/src/render3/view/template.ts | 85 ++++++---- .../core/src/core_render3_private_export.ts | 3 + packages/core/src/render3/index.ts | 1 + packages/core/src/render3/instructions.ts | 24 ++- .../core/src/render3/interfaces/renderer.ts | 10 +- packages/core/src/render3/interfaces/view.ts | 6 +- packages/core/src/render3/jit/environment.ts | 3 + .../core/src/render3/node_manipulation.ts | 8 +- packages/core/src/render3/util.ts | 14 +- packages/core/test/linker/integration_spec.ts | 131 +++++++-------- packages/core/test/render3/listeners_spec.ts | 158 ++++++++++++++++-- packages/core/test/render3/render_util.ts | 12 ++ 15 files changed, 373 insertions(+), 149 deletions(-) diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index db29fd0d0b..b5441c4894 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -588,11 +588,14 @@ describe('ngtsc behavioral tests', () => { template: 'Test' }) class FooCmp { + @HostListener('click') + onClick(event: any): void {} + @HostListener('document:click', ['$event.target']) - onClick(eventTarget: HTMLElement): void {} + onDocumentClick(eventTarget: HTMLElement): void {} @HostListener('window:scroll') - onScroll(event: any): void {} + onWindowScroll(event: any): void {} } `); @@ -601,14 +604,35 @@ describe('ngtsc behavioral tests', () => { const hostBindingsFn = ` hostBindings: function FooCmp_HostBindings(rf, ctx, elIndex) { if (rf & 1) { - i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onClick($event.target); }); - i0.ɵlistener("scroll", function FooCmp_scroll_HostBindingHandler($event) { return ctx.onScroll(); }); + i0.ɵlistener("click", function FooCmp_click_HostBindingHandler($event) { return ctx.onClick(); }); + 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)); }); + 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', () => { env.tsconfig(); env.write(`test.ts`, ` @@ -620,6 +644,7 @@ describe('ngtsc behavioral tests', () => { host: { '[attr.hello]': 'foo', '(click)': 'onClick($event)', + '(body:click)': 'onBodyClick($event)', '[prop]': 'bar', }, }) @@ -641,6 +666,7 @@ describe('ngtsc behavioral tests', () => { if (rf & 1) { 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.onBodyClick($event); }, false, i0.ɵresolveBody); i0.ɵlistener("change", function FooCmp_change_HostBindingHandler($event) { return ctx.onChange(ctx.arg1, ctx.arg2, ctx.arg3); }); i0.ɵelementStyling(_c0, null, null, ctx); } diff --git a/packages/compiler/src/render3/r3_identifiers.ts b/packages/compiler/src/render3/r3_identifiers.ts index 90e33da888..92c3820425 100644 --- a/packages/compiler/src/render3/r3_identifiers.ts +++ b/packages/compiler/src/render3/r3_identifiers.ts @@ -131,6 +131,10 @@ export class Identifiers { static templateRefExtractor: 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 BaseDef: o.ExternalReference = { diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 201e98e212..e881270c1b 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -9,7 +9,7 @@ import {StaticSymbol} from '../../aot/static_symbol'; import {CompileDirectiveMetadata, CompileDirectiveSummary, CompileQueryMetadata, CompileTokenMetadata, identifierName, sanitizeIdentifier} from '../../compile_metadata'; 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 * as core from '../../core'; 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 {BindingParser} from '../../template_parser/binding_parser'; import {OutputContext, error} from '../../util'; +import {BoundEvent} from '../r3_ast'; import {compileFactoryFunction, dependenciesFromGlobalMetadata} from '../r3_factory'; import {Identifiers as R3} from '../r3_identifiers'; 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 {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'; const EMPTY_ARRAY: any[] = []; @@ -809,21 +810,15 @@ function createHostListeners( bindingContext: o.Expression, eventBindings: ParsedEvent[], meta: R3DirectiveMetadata): o.Statement[] { return eventBindings.map(binding => { - const bindingExpr = convertActionBinding( - null, bindingContext, binding.handler, 'b', () => error('Unexpected interpolation')); let bindingName = binding.name && sanitizeIdentifier(binding.name); - let bindingFnName = bindingName; - if (binding.type === ParsedEventType.Animation) { - bindingFnName = prepareSyntheticListenerFunctionName(bindingName, binding.targetOrPhase); - bindingName = prepareSyntheticListenerName(bindingName, binding.targetOrPhase); - } - const typeName = meta.name; - const functionName = - typeName && bindingName ? `${typeName}_${bindingFnName}_HostBindingHandler` : null; - 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(); + const bindingFnName = binding.type === ParsedEventType.Animation ? + prepareSyntheticListenerFunctionName(bindingName, binding.targetOrPhase) : + bindingName; + const handlerName = + meta.name && bindingName ? `${meta.name}_${bindingFnName}_HostBindingHandler` : null; + const params = prepareEventListenerParameters( + BoundEvent.fromParsedEvent(binding), bindingContext, handlerName); + return o.importExpr(R3.listener).callFn(params).toStmt(); }); } diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index dc3cd9f38b..e9d085bee3 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -39,6 +39,16 @@ import {I18N_ICU_MAPPING_PREFIX, assembleBoundTextPlaceholders, assembleI18nBoun 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'; +// Default selector used by `` if none specified +const DEFAULT_NG_CONTENT_SELECTOR = '*'; + +// Selector attribute name of `` +const NG_CONTENT_SELECT_ATTR = 'select'; + +// List of supported global targets for event listeners +const GLOBAL_TARGET_RESOLVERS = new Map( + [['window', R3.resolveWindow], ['document', R3.resolveDocument], ['body', R3.resolveBody]]); + function mapBindingToInstruction(type: BindingType): o.ExternalReference|undefined { switch (type) { case BindingType.Property: @@ -59,11 +69,39 @@ export function renderFlagCheckIfStmt( return o.ifStmt(o.variable(RENDER_FLAGS).bitwiseAnd(o.literal(flags), null, false), statements); } -// Default selector used by `` if none specified -const DEFAULT_NG_CONTENT_SELECTOR = '*'; +export function prepareEventListenerParameters( + 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 `` -const NG_CONTENT_SELECT_ATTR = 'select'; + const bindingExpr = convertActionBinding( + 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, LocalResolver { private _dataIndex = 0; @@ -1069,37 +1107,16 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver private prepareListenerParameter(tagName: string, outputAst: t.BoundEvent, index: number): () => 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 () => { - - const listenerScope = this._bindingScope.nestedScope(this._bindingScope.bindingLevel); - - const bindingExpr = convertActionBinding( - listenerScope, o.variable(CONTEXT_NAME), outputAst.handler, 'b', - () => error('Unexpected interpolation')); - - const statements = [ - ...listenerScope.restoreViewStatement(), ...listenerScope.variableDeclarations(), - ...bindingExpr.render3Stmts - ]; - - const handler = o.fn( - [new o.FnParam('$event', o.DYNAMIC_TYPE)], statements, o.INFERRED_TYPE, null, - functionName); - return [o.literal(eventName), handler]; + const eventName: string = outputAst.name; + const bindingFnName = outputAst.type === ParsedEventType.Animation ? + // synthetic @listener.foo values are treated the exact same as are standard listeners + prepareSyntheticListenerFunctionName(eventName, outputAst.phase !) : + sanitizeIdentifier(eventName); + const handlerName = `${this.templateName}_${tagName}_${bindingFnName}_${index}_listener`; + const scope = this._bindingScope.nestedScope(this._bindingScope.bindingLevel); + const context = o.variable(CONTEXT_NAME); + return prepareEventListenerParameters(outputAst, context, handlerName, scope); }; } } diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index a4b8f95ff6..27741a3070 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -120,6 +120,9 @@ export { i18nApply as ɵi18nApply, i18nPostprocess as ɵi18nPostprocess, setClassMetadata as ɵsetClassMetadata, + resolveWindow as ɵresolveWindow, + resolveDocument as ɵresolveDocument, + resolveBody as ɵresolveBody, } from './render3/index'; diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 090b3df349..6d1ac2ea6a 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -144,6 +144,7 @@ export { export {templateRefExtractor} from './view_engine_compatibility_prebound'; +export {resolveWindow, resolveDocument, resolveBody} from './util'; // clang-format on diff --git a/packages/core/src/render3/instructions.ts b/packages/core/src/render3/instructions.ts index bfba0db10b..65d99a86f9 100644 --- a/packages/core/src/render3/instructions.ts +++ b/packages/core/src/render3/instructions.ts @@ -29,7 +29,7 @@ import {AttributeMarker, InitialInputData, InitialInputs, LocalRefExtractor, Pro import {PlayerFactory} from './interfaces/player'; import {CssSelectorList, NG_PROJECT_AS_ATTR_NAME} from './interfaces/projection'; 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 {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'; @@ -822,10 +822,13 @@ export function locateHostElement( * * @param eventName Name of the event * @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( - eventName: string, listenerFn: (e?: any) => any, useCapture = false): void { + eventName: string, listenerFn: (e?: any) => any, useCapture = false, + eventTargetResolver?: GlobalTargetResolver): void { const lView = getLView(); const tNode = getPreviousOrParentTNode(); const tView = lView[TVIEW]; @@ -837,6 +840,8 @@ export function listener( // add native event listener - applicable to elements only if (tNode.type === TNodeType.Element) { const native = getNativeByTNode(tNode, lView) as RElement; + const resolved = eventTargetResolver ? eventTargetResolver(native) : {} as any; + const target = resolved.target || native; ngDevMode && ngDevMode.rendererAddEventListener++; const renderer = lView[RENDERER]; 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 // events (including outputs). 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); useCaptureOrSubIdx = lCleanupIndex + 1; } else { const wrappedListener = wrapListenerWithPreventDefault(listenerFn); - native.addEventListener(eventName, wrappedListener, useCapture); + target.addEventListener(eventName, wrappedListener, useCapture); 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 diff --git a/packages/core/src/render3/interfaces/renderer.ts b/packages/core/src/render3/interfaces/renderer.ts index c54ee5326b..071fd958cf 100644 --- a/packages/core/src/render3/interfaces/renderer.ts +++ b/packages/core/src/render3/interfaces/renderer.ts @@ -26,6 +26,12 @@ export enum RendererStyleFlags3 { 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. * @@ -86,7 +92,9 @@ export interface ProceduralRenderer3 { setValue(node: RText|RComment, value: string): void; // 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 { diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 11e36b84da..cb95730705 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -446,7 +446,11 @@ export interface TView { * * If it's a native DOM listener or output subscription being stored: * 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]]` * 4th index is: `useCaptureOrIndx = tView.cleanup[i+3]` * `typeof useCaptureOrIndx == 'boolean' : useCapture boolean diff --git a/packages/core/src/render3/jit/environment.ts b/packages/core/src/render3/jit/environment.ts index 5ce7a8fb08..ffc043661e 100644 --- a/packages/core/src/render3/jit/environment.ts +++ b/packages/core/src/render3/jit/environment.ts @@ -107,6 +107,9 @@ export const angularCoreEnv: {[name: string]: Function} = { 'ɵi18nEnd': r3.i18nEnd, 'ɵi18nApply': r3.i18nApply, 'ɵi18nPostprocess': r3.i18nPostprocess, + 'ɵresolveWindow': r3.resolveWindow, + 'ɵresolveDocument': r3.resolveDocument, + 'ɵresolveBody': r3.resolveBody, 'ɵsanitizeHtml': sanitization.sanitizeHtml, 'ɵsanitizeStyle': sanitization.sanitizeStyle, diff --git a/packages/core/src/render3/node_manipulation.ts b/packages/core/src/render3/node_manipulation.ts index bd7834549e..3d39d92bb9 100644 --- a/packages/core/src/render3/node_manipulation.ts +++ b/packages/core/src/render3/node_manipulation.ts @@ -445,13 +445,15 @@ function removeListeners(lView: LView): void { for (let i = 0; i < tCleanup.length - 1; i += 2) { if (typeof tCleanup[i] === 'string') { // 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 native = readElementValue(lView[idx]); const useCaptureOrSubIdx = tCleanup[i + 3]; if (typeof useCaptureOrSubIdx === 'boolean') { // DOM listener - native.removeEventListener(tCleanup[i], listener, useCaptureOrSubIdx); + target.removeEventListener(tCleanup[i], listener, useCaptureOrSubIdx); } else { if (useCaptureOrSubIdx >= 0) { // unregister diff --git a/packages/core/src/render3/util.ts b/packages/core/src/render3/util.ts index d4d269b3a8..440894bb26 100644 --- a/packages/core/src/render3/util.ts +++ b/packages/core/src/render3/util.ts @@ -14,7 +14,7 @@ import {LContext, MONKEY_PATCH_KEY_NAME} from './interfaces/context'; import {ComponentDef, DirectiveDef} from './interfaces/definition'; import {NO_PARENT_INJECTOR, RelativeInjectorLocation, RelativeInjectorLocationFlags} from './interfaces/injector'; 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 {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; } + +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}; +} \ No newline at end of file diff --git a/packages/core/test/linker/integration_spec.ts b/packages/core/test/linker/integration_spec.ts index 7c6788f272..91e5c221c0 100644 --- a/packages/core/test/linker/integration_spec.ts +++ b/packages/core/test/linker/integration_spec.ts @@ -845,52 +845,48 @@ function declareTests(config?: {useJit: boolean}) { dir.triggerChange('two'); })); - fixmeIvy( - 'FW-743: Registering events on global objects (document, window, body) is not supported') - .it('should support render events', () => { - TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]}); - const template = '
'; - TestBed.overrideComponent(MyComp, {set: {template}}); - const fixture = TestBed.createComponent(MyComp); + it('should support render events', () => { + TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]}); + const template = '
'; + TestBed.overrideComponent(MyComp, {set: {template}}); + const fixture = TestBed.createComponent(MyComp); - const tc = fixture.debugElement.children[0]; - const listener = tc.injector.get(DirectiveListeningDomEvent); + const tc = fixture.debugElement.children[0]; + const listener = tc.injector.get(DirectiveListeningDomEvent); - dispatchEvent(tc.nativeElement, 'domEvent'); + dispatchEvent(tc.nativeElement, 'domEvent'); - expect(listener.eventTypes).toEqual([ - 'domEvent', 'body_domEvent', 'document_domEvent', 'window_domEvent' - ]); + expect(listener.eventTypes).toEqual([ + 'domEvent', 'body_domEvent', 'document_domEvent', 'window_domEvent' + ]); - fixture.destroy(); - listener.eventTypes = []; - dispatchEvent(tc.nativeElement, 'domEvent'); - expect(listener.eventTypes).toEqual([]); - }); + fixture.destroy(); + listener.eventTypes = []; + dispatchEvent(tc.nativeElement, 'domEvent'); + expect(listener.eventTypes).toEqual([]); + }); - fixmeIvy( - 'FW-743: Registering events on global objects (document, window, body) is not supported') - .it('should support render global events', () => { - TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]}); - const template = '
'; - TestBed.overrideComponent(MyComp, {set: {template}}); - const fixture = TestBed.createComponent(MyComp); - const doc = TestBed.get(DOCUMENT); + it('should support render global events', () => { + TestBed.configureTestingModule({declarations: [MyComp, DirectiveListeningDomEvent]}); + const template = '
'; + TestBed.overrideComponent(MyComp, {set: {template}}); + const fixture = TestBed.createComponent(MyComp); + const doc = TestBed.get(DOCUMENT); - const tc = fixture.debugElement.children[0]; - const listener = tc.injector.get(DirectiveListeningDomEvent); - dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); - expect(listener.eventTypes).toEqual(['window_domEvent']); + const tc = fixture.debugElement.children[0]; + const listener = tc.injector.get(DirectiveListeningDomEvent); + dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); + expect(listener.eventTypes).toEqual(['window_domEvent']); - listener.eventTypes = []; - dispatchEvent(getDOM().getGlobalEventTarget(doc, 'document'), 'domEvent'); - expect(listener.eventTypes).toEqual(['document_domEvent', 'window_domEvent']); + listener.eventTypes = []; + dispatchEvent(getDOM().getGlobalEventTarget(doc, 'document'), 'domEvent'); + expect(listener.eventTypes).toEqual(['document_domEvent', 'window_domEvent']); - fixture.destroy(); - listener.eventTypes = []; - dispatchEvent(getDOM().getGlobalEventTarget(doc, 'body'), 'domEvent'); - expect(listener.eventTypes).toEqual([]); - }); + fixture.destroy(); + listener.eventTypes = []; + dispatchEvent(getDOM().getGlobalEventTarget(doc, 'body'), 'domEvent'); + expect(listener.eventTypes).toEqual([]); + }); it('should support updating host element via hostAttributes on root elements', () => { @Component({host: {'role': 'button'}, template: ''}) @@ -1027,44 +1023,41 @@ function declareTests(config?: {useJit: boolean}) { }); } - fixmeIvy( - 'FW-743: Registering events on global objects (document, window, body) is not supported') - .it('should support render global events from multiple directives', () => { - TestBed.configureTestingModule({ - declarations: [MyComp, DirectiveListeningDomEvent, DirectiveListeningDomEventOther] - }); - const template = '
'; - TestBed.overrideComponent(MyComp, {set: {template}}); - const fixture = TestBed.createComponent(MyComp); - const doc = TestBed.get(DOCUMENT); + it('should support render global events from multiple directives', () => { + TestBed.configureTestingModule( + {declarations: [MyComp, DirectiveListeningDomEvent, DirectiveListeningDomEventOther]}); + const template = '
'; + TestBed.overrideComponent(MyComp, {set: {template}}); + const fixture = TestBed.createComponent(MyComp); + const doc = TestBed.get(DOCUMENT); - globalCounter = 0; - fixture.componentInstance.ctxBoolProp = true; - fixture.detectChanges(); + globalCounter = 0; + fixture.componentInstance.ctxBoolProp = true; + fixture.detectChanges(); - const tc = fixture.debugElement.children[0]; + const tc = fixture.debugElement.children[0]; - const listener = tc.injector.get(DirectiveListeningDomEvent); - const listenerother = tc.injector.get(DirectiveListeningDomEventOther); - dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); - expect(listener.eventTypes).toEqual(['window_domEvent']); - expect(listenerother.eventType).toEqual('other_domEvent'); - expect(globalCounter).toEqual(1); + const listener = tc.injector.get(DirectiveListeningDomEvent); + const listenerother = tc.injector.get(DirectiveListeningDomEventOther); + dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); + expect(listener.eventTypes).toEqual(['window_domEvent']); + expect(listenerother.eventType).toEqual('other_domEvent'); + expect(globalCounter).toEqual(1); - fixture.componentInstance.ctxBoolProp = false; - fixture.detectChanges(); - dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); - expect(globalCounter).toEqual(1); + fixture.componentInstance.ctxBoolProp = false; + fixture.detectChanges(); + dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); + expect(globalCounter).toEqual(1); - fixture.componentInstance.ctxBoolProp = true; - fixture.detectChanges(); - dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); - expect(globalCounter).toEqual(2); + fixture.componentInstance.ctxBoolProp = true; + fixture.detectChanges(); + dispatchEvent(getDOM().getGlobalEventTarget(doc, 'window'), 'domEvent'); + expect(globalCounter).toEqual(2); - // need to destroy to release all remaining global event listeners - fixture.destroy(); - }); + // need to destroy to release all remaining global event listeners + fixture.destroy(); + }); describe('ViewContainerRef', () => { beforeEach(() => { diff --git a/packages/core/test/render3/listeners_spec.ts b/packages/core/test/render3/listeners_spec.ts index fd1473cf2b..c063d242a9 100644 --- a/packages/core/test/render3/listeners_spec.ts +++ b/packages/core/test/render3/listeners_spec.ts @@ -6,17 +6,21 @@ * 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 {RenderFlags} from '../../src/render3/interfaces/definition'; +import {GlobalTargetResolver} from '../../src/render3/interfaces/renderer'; import {restoreView} from '../../src/render3/state'; 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', () => { - let comps: MyComp[] = []; + let comps: any[] = []; + let events: any[] = []; class MyComp { 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 { handlerReturnValue: any = true; // TODO(issue/24571): remove '!'. @@ -84,7 +149,10 @@ describe('event listeners', () => { }); } - beforeEach(() => { comps = []; }); + beforeEach(() => { + comps = []; + events = []; + }); it('should call function on event emit', () => { const fixture = new ComponentFixture(MyComp); @@ -477,6 +545,7 @@ describe('event listeners', () => { const fixture = new ComponentFixture(MyComp); const host = fixture.hostElement; + host.click(); expect(events).toEqual(['click!']); @@ -484,6 +553,20 @@ describe('event listeners', () => { 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', () => { let events: string[] = []; @@ -504,16 +587,14 @@ describe('event listeners', () => { }); } - function Template(rf: RenderFlags, ctx: any) { - if (rf & RenderFlags.Create) { - elementStart(0, 'button', ['hostListenerDir', '']); - text(1, 'Click'); - elementEnd(); - } - } + const fixture = new TemplateFixture(() => { + elementStart(0, 'button', ['hostListenerDir', '']); + text(1, 'Click'); + elementEnd(); + }, () => {}, 2, 0, [HostListenerDir]); + + const button = fixture.hostElement.querySelector('button') !; - renderToHtml(Template, {}, 2, 0, [HostListenerDir]); - const button = containerEl.querySelector('button') !; button.click(); expect(events).toEqual(['click!']); @@ -521,6 +602,23 @@ describe('event listeners', () => { 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', () => { class MyComp { counter = 0; @@ -673,6 +771,40 @@ describe('event listeners', () => { 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', () => { /** * % if (condition) { diff --git a/packages/core/test/render3/render_util.ts b/packages/core/test/render3/render_util.ts index 2ec2ecfb50..64304962c3 100644 --- a/packages/core/test/render3/render_util.ts +++ b/packages/core/test/render3/render_util.ts @@ -31,6 +31,8 @@ import {DirectiveDefList, DirectiveTypesOrFactory, PipeDef, PipeDefList, PipeTyp import {PlayerHandler} from '../../src/render3/interfaces/player'; 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 {destroyLView} from '../../src/render3/node_manipulation'; +import {getRootView} from '../../src/render3/util'; import {Sanitizer} from '../../src/sanitization/security'; import {Type} from '../../src/type'; @@ -130,6 +132,11 @@ export class TemplateFixture extends BaseFixture { this.hostElement, updateBlock || this.updateBlock, 0, this.vars, null !, 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 extends BaseFixture { tick(this.component); this.requestAnimationFrame.flush(); } + + destroy(): void { + this.containerElement.removeChild(this.hostElement); + destroyLView(getRootView(this.component)); + } } ///////////////////////////////////////////////////////////////////////////////////