diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 8fc09353b26..49c368a10bd 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -20,7 +20,7 @@ }, "forms": { "uncompressed": { - "main": 171726, + "main": 176726, "polyfills": 33772 } }, diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 9f9b08a54e5..ebdfd9d1871 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -71,6 +71,12 @@ export { withI18nSupport as ɵwithI18nSupport, } from './hydration/api'; export {withEventReplay as ɵwithEventReplay} from './hydration/event_replay'; +export { + GLOBAL_EVENT_DELEGATION as ɵGLOBAL_EVENT_DELEGATION, + JSACTION_EVENT_CONTRACT as ɵJSACTION_EVENT_CONTRACT, + setJSActionAttribute as ɵsetJSActionAttribute, +} from './event_delegation_utils'; +export {provideGlobalEventDelegation as ɵprovideGlobalEventDelegation} from './event_dispatch/event_delegation'; export {IS_HYDRATION_DOM_REUSE_ENABLED as ɵIS_HYDRATION_DOM_REUSE_ENABLED} from './hydration/tokens'; export { HydratedNode as ɵHydratedNode, diff --git a/packages/core/src/event_delegation_utils.ts b/packages/core/src/event_delegation_utils.ts index 35a773f8440..327e072baf3 100644 --- a/packages/core/src/event_delegation_utils.ts +++ b/packages/core/src/event_delegation_utils.ts @@ -11,10 +11,11 @@ import { EventContract, EventContractContainer, EventDispatcher, + isSupportedEvent, registerDispatcher, } from '@angular/core/primitives/event-dispatch'; import * as Attributes from '@angular/core/primitives/event-dispatch'; -import {Injectable, Injector} from './di'; +import {Injectable, InjectionToken, Injector, inject} from './di'; import {RElement} from './render3/interfaces/renderer_dom'; import {EVENT_REPLAY_ENABLED_DEFAULT, IS_EVENT_REPLAY_ENABLED} from './hydration/tokens'; @@ -34,16 +35,21 @@ export function invokeRegisteredListeners(event: Event) { } } -export function setJSActionAttribute(nativeElement: Element, eventTypes: string[]) { +export function setJSActionAttributes(nativeElement: Element, eventTypes: string[]) { if (!eventTypes.length) { return; } const parts = eventTypes.reduce((prev, curr) => prev + curr + ':;', ''); const existingAttr = nativeElement.getAttribute(Attributes.JSACTION); - // This is required to be a module accessor to appease security tests on setAttribute. nativeElement.setAttribute(Attributes.JSACTION, `${existingAttr ?? ''}${parts}`); } +export function setJSActionAttribute(nativeElement: Element, eventType: string) { + const existingAttr = nativeElement.getAttribute(Attributes.JSACTION); + // This is required to be a module accessor to appease security tests on setAttribute. + nativeElement.setAttribute(Attributes.JSACTION, `${existingAttr ?? ''}${eventType}:;`); +} + export const sharedStashFunction = (rEl: RElement, eventType: string, listenerFn: () => void) => { const el = rEl as unknown as Element; const eventListenerMap = el.__jsaction_fns ?? new Map(); @@ -58,30 +64,62 @@ export const removeListeners = (el: Element) => { el.__jsaction_fns = undefined; }; -@Injectable({providedIn: 'root'}) +export interface EventContractDetails { + instance?: EventContract; +} + +export const JSACTION_EVENT_CONTRACT = new InjectionToken( + ngDevMode ? 'EVENT_CONTRACT_DETAILS' : '', + { + providedIn: 'root', + factory: () => ({}), + }, +); + +export const GLOBAL_EVENT_DELEGATION = new InjectionToken( + ngDevMode ? 'GLOBAL_EVENT_DELEGATION' : '', +); + +/** + * This class is the delegate for `EventDelegationPlugin`. It represents the + * noop version of this class, with the enabled version set when + * `provideGlobalEventDelegation` is called. + */ +@Injectable() export class GlobalEventDelegation { - eventContract!: EventContract; - addEvent(el: Element, eventName: string) { - if (this.eventContract) { - this.eventContract.addEvent(eventName); - setJSActionAttribute(el, [eventName]); - return true; - } - return false; + private eventContractDetails = inject(JSACTION_EVENT_CONTRACT); + + supports(eventName: string): boolean { + return isSupportedEvent(eventName); + } + + addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { + this.eventContractDetails.instance!.addEvent(eventName); + setJSActionAttribute(element, eventName); + return () => this.removeEventListener(element, eventName, handler); + } + + removeEventListener(element: HTMLElement, eventName: string, callback: Function): void { + const newJsactionAttribute = element + .getAttribute(Attributes.JSACTION) + ?.split(';') + .filter((s) => s === eventName + ':') + .join(';'); + element.setAttribute(Attributes.JSACTION, newJsactionAttribute ?? ''); } } export const initGlobalEventDelegation = ( - eventDelegation: GlobalEventDelegation, + eventContractDetails: EventContractDetails, injector: Injector, ) => { if (injector.get(IS_EVENT_REPLAY_ENABLED, EVENT_REPLAY_ENABLED_DEFAULT)) { return; } - eventDelegation.eventContract = new EventContract( + const eventContract = (eventContractDetails.instance = new EventContract( new EventContractContainer(document.body), /* useActionResolver= */ false, - ); + )); const dispatcher = new EventDispatcher(invokeRegisteredListeners); - registerDispatcher(eventDelegation.eventContract, dispatcher); + registerDispatcher(eventContract, dispatcher); }; diff --git a/packages/core/src/event_dispatch/event_delegation.ts b/packages/core/src/event_dispatch/event_delegation.ts new file mode 100644 index 00000000000..05a23e52dec --- /dev/null +++ b/packages/core/src/event_dispatch/event_delegation.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ENVIRONMENT_INITIALIZER, Injector} from '../di'; +import {inject} from '../di/injector_compatibility'; +import {Provider} from '../di/interface/provider'; +import {setStashFn} from '../render3/instructions/listener'; +import { + GLOBAL_EVENT_DELEGATION, + GlobalEventDelegation, + JSACTION_EVENT_CONTRACT, + initGlobalEventDelegation, + sharedStashFunction, +} from '../event_delegation_utils'; + +import {IS_GLOBAL_EVENT_DELEGATION_ENABLED} from '../hydration/tokens'; + +/** + * Returns a set of providers required to setup support for event delegation. + */ +export function provideGlobalEventDelegation(): Provider[] { + return [ + { + provide: IS_GLOBAL_EVENT_DELEGATION_ENABLED, + useValue: true, + }, + { + provide: ENVIRONMENT_INITIALIZER, + useValue: () => { + const injector = inject(Injector); + const eventContractDetails = injector.get(JSACTION_EVENT_CONTRACT); + initGlobalEventDelegation(eventContractDetails, injector); + setStashFn(sharedStashFunction); + }, + multi: true, + }, + { + provide: GLOBAL_EVENT_DELEGATION, + useClass: GlobalEventDelegation, + }, + ]; +} diff --git a/packages/core/src/hydration/annotate.ts b/packages/core/src/hydration/annotate.ts index cd3e70c7069..9859556a207 100644 --- a/packages/core/src/hydration/annotate.ts +++ b/packages/core/src/hydration/annotate.ts @@ -40,7 +40,7 @@ import {TransferState} from '../transfer_state'; import {unsupportedProjectionOfDomNodes} from './error_handling'; import {collectDomEventsInfo} from './event_replay'; -import {setJSActionAttribute} from '../event_delegation_utils'; +import {setJSActionAttributes} from '../event_delegation_utils'; import { getOrComputeI18nChildren, isI18nHydrationEnabled, @@ -458,7 +458,7 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView if (nativeElementsToEventTypes && tNode.type & TNodeType.Element) { const nativeElement = unwrapRNode(lView[i]) as Element; if (nativeElementsToEventTypes.has(nativeElement)) { - setJSActionAttribute(nativeElement, nativeElementsToEventTypes.get(nativeElement)!); + setJSActionAttributes(nativeElement, nativeElementsToEventTypes.get(nativeElement)!); } } diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index a5c30e8f1b3..d85457bf42b 100644 --- a/packages/core/src/hydration/event_replay.ts +++ b/packages/core/src/hydration/event_replay.ts @@ -32,10 +32,11 @@ import { IS_GLOBAL_EVENT_DELEGATION_ENABLED, } from './tokens'; import { - GlobalEventDelegation, sharedStashFunction, removeListeners, invokeRegisteredListeners, + EventContractDetails, + JSACTION_EVENT_CONTRACT, } from '../event_delegation_utils'; import {APP_ID} from '../application/application_tokens'; import {performanceMarkFeature} from '../util/performance'; @@ -116,8 +117,8 @@ export function withEventReplay(): Provider[] { // of the application is completed. This timing is similar to the unclaimed // dehydrated views cleanup timing. whenStable(appRef).then(() => { - const globalEventDelegation = injector.get(GlobalEventDelegation); - initEventReplay(globalEventDelegation, injector); + const eventContractDetails = injector.get(JSACTION_EVENT_CONTRACT); + initEventReplay(eventContractDetails, injector); jsactionSet.forEach(removeListeners); // After hydration, we shouldn't need to do anymore work related to // event replay anymore. @@ -137,12 +138,12 @@ function getJsactionData(container: EarlyJsactionDataContainer) { return container._ejsa; } -const initEventReplay = (eventDelegation: GlobalEventDelegation, injector: Injector) => { +const initEventReplay = (eventDelegation: EventContractDetails, injector: Injector) => { const appId = injector.get(APP_ID); // This is set in packages/platform-server/src/utils.ts const container = globalThis[CONTRACT_PROPERTY]?.[appId]; const earlyJsactionData = getJsactionData(container)!; - const eventContract = (eventDelegation.eventContract = new EventContract( + const eventContract = (eventDelegation.instance = new EventContract( new EventContractContainer(earlyJsactionData.c), /* useActionResolver= */ false, )); diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index 90752317e8a..1dca971913d 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -176,6 +176,9 @@ { "name": "DomAdapter" }, + { + "name": "DomEventsPlugin" + }, { "name": "DomRendererFactory2" }, @@ -230,6 +233,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -242,6 +248,9 @@ { "name": "FALSE_BOOLEAN_VALUES" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index aca4448eda1..929aff7a025 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -254,6 +254,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -266,6 +269,9 @@ { "name": "FALSE_BOOLEAN_VALUES" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 95703fb2b9d..d195a342470 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -161,6 +161,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -170,6 +173,9 @@ { "name": "EventManagerPlugin" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index ecf9d64984a..3608a20c650 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -194,6 +194,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -203,6 +206,9 @@ { "name": "EventManagerPlugin" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, @@ -1415,6 +1421,9 @@ { "name": "init_event_contract_defines" }, + { + "name": "init_event_delegation" + }, { "name": "init_event_delegation_utils" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 4f0d50baa3d..2a9e3a04366 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -221,6 +221,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -266,6 +269,9 @@ { "name": "FormsExampleModule" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 92b9edd8398..25c9180b6ba 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -224,6 +224,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -245,6 +248,9 @@ { "name": "FormsModule" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 3ac4c936b5a..1b805a3fc88 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -122,6 +122,9 @@ { "name": "DomAdapter" }, + { + "name": "DomEventsPlugin" + }, { "name": "DomRendererFactory2" }, @@ -164,6 +167,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -173,6 +179,9 @@ { "name": "EventManagerPlugin" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index bbc1b92c044..185e6935b71 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -185,6 +185,9 @@ { "name": "DomAdapter" }, + { + "name": "DomEventsPlugin" + }, { "name": "DomRendererFactory2" }, @@ -230,6 +233,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -242,6 +248,9 @@ { "name": "EventType2" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 3abc4dca1e5..396d025f1a8 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -104,6 +104,9 @@ { "name": "DomAdapter" }, + { + "name": "DomEventsPlugin" + }, { "name": "DomRendererFactory2" }, @@ -143,6 +146,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -152,6 +158,9 @@ { "name": "EventManagerPlugin" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index b134b33feda..d6e259ceef9 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -164,6 +164,9 @@ { "name": "ErrorHandler" }, + { + "name": "EventDelegationPlugin" + }, { "name": "EventEmitter" }, @@ -173,6 +176,9 @@ { "name": "EventManagerPlugin" }, + { + "name": "GLOBAL_EVENT_DELEGATION" + }, { "name": "GenericBrowserDomAdapter" }, diff --git a/packages/core/test/event_dispatch/BUILD.bazel b/packages/core/test/event_dispatch/BUILD.bazel new file mode 100644 index 00000000000..b3a73d92ad3 --- /dev/null +++ b/packages/core/test/event_dispatch/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "karma_web_test_suite", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = [ + "event_dispatch_spec.ts", + ], + deps = [ + "//packages/core", + "//packages/core/testing", + "//packages/platform-browser", + ], +) + +karma_web_test_suite( + name = "test", + deps = [":test_lib"], +) diff --git a/packages/core/test/event_dispatch/event_dispatch_spec.ts b/packages/core/test/event_dispatch/event_dispatch_spec.ts new file mode 100644 index 00000000000..05cc2a97aec --- /dev/null +++ b/packages/core/test/event_dispatch/event_dispatch_spec.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, ɵJSACTION_EVENT_CONTRACT, ɵprovideGlobalEventDelegation} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; + +function configureTestingModule(components: unknown[]) { + TestBed.configureTestingModule({ + imports: components, + providers: [ɵprovideGlobalEventDelegation()], + }); +} + +describe('event dispatch', () => { + let fixture: ComponentFixture; + afterEach(() => fixture.debugElement.injector.get(ɵJSACTION_EVENT_CONTRACT).instance?.cleanUp()); + + it(`executes an onclick handler`, async () => { + const onClickSpy = jasmine.createSpy(); + @Component({ + selector: 'app', + standalone: true, + template: ` + + `, + }) + class AppComponent { + onClick = onClickSpy; + } + configureTestingModule([AppComponent]); + const addEventListenerSpy = spyOn( + HTMLButtonElement.prototype, + 'addEventListener', + ).and.callThrough(); + fixture = TestBed.createComponent(AppComponent); + const button = (fixture.debugElement.nativeElement as Element) + .firstElementChild as HTMLButtonElement; + button.click(); + expect(onClickSpy).toHaveBeenCalledTimes(1); + expect(button.hasAttribute('jsaction')).toBeTrue(); + expect(addEventListenerSpy).not.toHaveBeenCalled(); + }); + + it('should work for elements with local refs', async () => { + const onClickSpy = jasmine.createSpy(); + + @Component({ + selector: 'app', + standalone: true, + template: ` + + `, + }) + class AppComponent { + onClick = onClickSpy; + } + configureTestingModule([AppComponent]); + fixture = TestBed.createComponent(AppComponent); + fixture.debugElement.nativeElement.querySelector('#btn').click(); + expect(onClickSpy).toHaveBeenCalled(); + }); + it('should route to the appropriate component with content projection', async () => { + const outerOnClickSpy = jasmine.createSpy(); + const innerOnClickSpy = jasmine.createSpy(); + @Component({ + selector: 'app-card', + standalone: true, + template: ` +
+ + +
+ `, + }) + class CardComponent { + onClick = innerOnClickSpy; + } + + @Component({ + selector: 'app', + imports: [CardComponent], + standalone: true, + template: ` + +

Card Title

+

This is some card content.

+ +
+ `, + }) + class AppComponent { + onClick = outerOnClickSpy; + } + configureTestingModule([AppComponent]); + fixture = TestBed.createComponent(AppComponent); + const nativeElement = fixture.debugElement.nativeElement; + const outer = nativeElement.querySelector('#outer-button')!; + const inner = nativeElement.querySelector('#inner-button')!; + outer.click(); + inner.click(); + expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy); + }); + it('should serialize event types to be listened to and jsaction attribute', async () => { + const clickSpy = jasmine.createSpy('onClick'); + const focusSpy = jasmine.createSpy('onFocus'); + @Component({ + standalone: true, + selector: 'app', + template: ` +
+
+
+ +
+
+
+ `, + }) + class SimpleComponent { + onClick = clickSpy; + onFocus = focusSpy; + } + configureTestingModule([SimpleComponent]); + fixture = TestBed.createComponent(SimpleComponent); + const nativeElement = fixture.debugElement.nativeElement; + const el = nativeElement.querySelector('#click-element')!; + const button = nativeElement.querySelector('#focus-target-element')!; + const clickEvent = new CustomEvent('click', {bubbles: true}); + el.dispatchEvent(clickEvent); + const focusEvent = new CustomEvent('focus'); + button.dispatchEvent(focusEvent); + expect(clickSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + describe('bubbling behavior', () => { + it('should propagate events', async () => { + const onClickSpy = jasmine.createSpy(); + @Component({ + standalone: true, + selector: 'app', + template: ` +
+
+
+ `, + }) + class SimpleComponent { + onClick = onClickSpy; + } + configureTestingModule([SimpleComponent]); + fixture = TestBed.createComponent(SimpleComponent); + const nativeElement = fixture.debugElement.nativeElement; + const bottomEl = nativeElement.querySelector('#bottom')!; + bottomEl.click(); + expect(onClickSpy).toHaveBeenCalledTimes(2); + }); + + it('should not propagate events if stopPropagation is called', async () => { + @Component({ + standalone: true, + selector: 'app', + template: ` +
+
+
+ `, + }) + class SimpleComponent { + onClick(e: Event) { + e.stopPropagation(); + } + } + const onClickSpy = spyOn(SimpleComponent.prototype, 'onClick').and.callThrough(); + configureTestingModule([SimpleComponent]); + fixture = TestBed.createComponent(SimpleComponent); + const nativeElement = fixture.debugElement.nativeElement; + const bottomEl = nativeElement.querySelector('#bottom')!; + bottomEl.click(); + expect(onClickSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/platform-browser/src/browser.ts b/packages/platform-browser/src/browser.ts index 4c7bc9d739a..fe1c1828d10 100644 --- a/packages/platform-browser/src/browser.ts +++ b/packages/platform-browser/src/browser.ts @@ -49,6 +49,7 @@ import {BrowserGetTestability} from './browser/testability'; import {BrowserXhr} from './browser/xhr'; import {DomRendererFactory2} from './dom/dom_renderer'; import {DomEventsPlugin} from './dom/events/dom_events'; +import {EventDelegationPlugin} from './dom/events/event_delegation'; import {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager'; import {KeyEventsPlugin} from './dom/events/key_events'; import {SharedStylesHost} from './dom/shared_styles_host'; @@ -240,6 +241,11 @@ const BROWSER_MODULE_PROVIDERS: Provider[] = [ deps: [DOCUMENT, NgZone, PLATFORM_ID], }, {provide: EVENT_MANAGER_PLUGINS, useClass: KeyEventsPlugin, multi: true, deps: [DOCUMENT]}, + { + provide: EVENT_MANAGER_PLUGINS, + useClass: EventDelegationPlugin, + multi: true, + }, DomRendererFactory2, SharedStylesHost, EventManager, diff --git a/packages/platform-browser/src/dom/events/event_delegation.ts b/packages/platform-browser/src/dom/events/event_delegation.ts new file mode 100644 index 00000000000..06b8299e1c3 --- /dev/null +++ b/packages/platform-browser/src/dom/events/event_delegation.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, Injectable, inject, ɵGLOBAL_EVENT_DELEGATION} from '@angular/core'; +import {EventManagerPlugin} from './event_manager'; +import {DOCUMENT} from '@angular/common'; + +@Injectable() +export class EventDelegationPlugin extends EventManagerPlugin { + private delegate = inject(ɵGLOBAL_EVENT_DELEGATION, {optional: true}); + constructor(@Inject(DOCUMENT) doc: any) { + super(doc); + } + + override supports(eventName: string): boolean { + // If `GlobalDelegationEventPlugin` implementation is not provided, + // this plugin is kept disabled. + return this.delegate ? this.delegate.supports(eventName) : false; + } + + override addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { + return this.delegate!.addEventListener(element, eventName, handler); + } + + removeEventListener(element: HTMLElement, eventName: string, callback: Function): void { + return this.delegate!.removeEventListener(element, eventName, callback); + } +} diff --git a/packages/platform-browser/test/dom/events/event_manager_spec.ts b/packages/platform-browser/test/dom/events/event_manager_spec.ts index f527d923f60..4aec1ddeef6 100644 --- a/packages/platform-browser/test/dom/events/event_manager_spec.ts +++ b/packages/platform-browser/test/dom/events/event_manager_spec.ts @@ -15,18 +15,22 @@ import { } from '@angular/platform-browser/src/dom/events/event_manager'; import {createMouseEvent, el} from '../../../testing/src/browser_util'; +import {TestBed} from '@angular/core/testing'; (function () { if (isNode) return; let domEventPlugin: DomEventsPlugin; let doc: Document; let zone: NgZone; + const {runInInjectionContext} = TestBed; describe('EventManager', () => { beforeEach(() => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({}); - domEventPlugin = new DomEventsPlugin(doc); + runInInjectionContext(() => { + domEventPlugin = new DomEventsPlugin(doc); + }); }); it('should delegate event bindings to plugins that are passed in from the most generic one to the most specific one', () => { @@ -328,7 +332,9 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util'; it('should only trigger one Change detection when bubbling with shouldCoalesceEventChangeDetection = true', (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceEventChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + runInInjectionContext(() => { + domEventPlugin = new DomEventsPlugin(doc); + }); const element = el('
'); const child = el('
'); element.appendChild(child); @@ -364,7 +370,9 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util'; it('should only trigger one Change detection when bubbling with shouldCoalesceRunChangeDetection = true', (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceRunChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + runInInjectionContext(() => { + domEventPlugin = new DomEventsPlugin(doc); + }); const element = el('
'); const child = el('
'); element.appendChild(child); @@ -400,7 +408,9 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util'; it('should not drain micro tasks queue too early with shouldCoalesceEventChangeDetection=true', (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceEventChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + runInInjectionContext(() => { + domEventPlugin = new DomEventsPlugin(doc); + }); const element = el('
'); const child = el('
'); doc.body.appendChild(element); @@ -446,7 +456,9 @@ import {createMouseEvent, el} from '../../../testing/src/browser_util'; it('should not drain micro tasks queue too early with shouldCoalesceRunChangeDetection=true', (done: DoneFn) => { doc = getDOM().supportsDOMEvents ? document : getDOM().createHtmlDocument(); zone = new NgZone({shouldCoalesceRunChangeDetection: true}); - domEventPlugin = new DomEventsPlugin(doc); + runInInjectionContext(() => { + domEventPlugin = new DomEventsPlugin(doc); + }); const element = el('
'); const child = el('
'); doc.body.appendChild(element);