refactor(core): Add global event delegation provider (#56247)

This replaces all addEventListener calls with a stashing function,
and installs an event listener on the document body to retrieve
the stashed function;

PR Close #56247
This commit is contained in:
Thomas Nguyen 2024-06-03 17:13:53 -07:00 committed by Jessica Janiuk
parent 3bfba7ec15
commit 40fb81fd0e
21 changed files with 454 additions and 29 deletions

View file

@ -20,7 +20,7 @@
},
"forms": {
"uncompressed": {
"main": 171726,
"main": 176726,
"polyfills": 33772
}
},

View file

@ -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,

View file

@ -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<EventContractDetails>(
ngDevMode ? 'EVENT_CONTRACT_DETAILS' : '',
{
providedIn: 'root',
factory: () => ({}),
},
);
export const GLOBAL_EVENT_DELEGATION = new InjectionToken<GlobalEventDelegation>(
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);
};

View file

@ -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,
},
];
}

View file

@ -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)!);
}
}

View file

@ -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,
));

View file

@ -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"
},

View file

@ -254,6 +254,9 @@
{
"name": "ErrorHandler"
},
{
"name": "EventDelegationPlugin"
},
{
"name": "EventEmitter"
},
@ -266,6 +269,9 @@
{
"name": "FALSE_BOOLEAN_VALUES"
},
{
"name": "GLOBAL_EVENT_DELEGATION"
},
{
"name": "GenericBrowserDomAdapter"
},

View file

@ -161,6 +161,9 @@
{
"name": "ErrorHandler"
},
{
"name": "EventDelegationPlugin"
},
{
"name": "EventEmitter"
},
@ -170,6 +173,9 @@
{
"name": "EventManagerPlugin"
},
{
"name": "GLOBAL_EVENT_DELEGATION"
},
{
"name": "GenericBrowserDomAdapter"
},

View file

@ -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"
},

View file

@ -221,6 +221,9 @@
{
"name": "ErrorHandler"
},
{
"name": "EventDelegationPlugin"
},
{
"name": "EventEmitter"
},
@ -266,6 +269,9 @@
{
"name": "FormsExampleModule"
},
{
"name": "GLOBAL_EVENT_DELEGATION"
},
{
"name": "GenericBrowserDomAdapter"
},

View file

@ -224,6 +224,9 @@
{
"name": "ErrorHandler"
},
{
"name": "EventDelegationPlugin"
},
{
"name": "EventEmitter"
},
@ -245,6 +248,9 @@
{
"name": "FormsModule"
},
{
"name": "GLOBAL_EVENT_DELEGATION"
},
{
"name": "GenericBrowserDomAdapter"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -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"
},

View file

@ -164,6 +164,9 @@
{
"name": "ErrorHandler"
},
{
"name": "EventDelegationPlugin"
},
{
"name": "EventEmitter"
},
@ -173,6 +176,9 @@
{
"name": "EventManagerPlugin"
},
{
"name": "GLOBAL_EVENT_DELEGATION"
},
{
"name": "GenericBrowserDomAdapter"
},

View file

@ -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"],
)

View file

@ -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<unknown>;
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: `
<button id="btn" (click)="onClick()"></button>
`,
})
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: `
<button id="btn" (click)="onClick()" #localRef></button>
`,
})
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: `
<div class="card">
<button id="inner-button" (click)="onClick()"></button>
<ng-content></ng-content>
</div>
`,
})
class CardComponent {
onClick = innerOnClickSpy;
}
@Component({
selector: 'app',
imports: [CardComponent],
standalone: true,
template: `
<app-card>
<h2>Card Title</h2>
<p>This is some card content.</p>
<button id="outer-button" (click)="onClick()">Click Me</button>
</app-card>
`,
})
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: `
<div (click)="onClick()" id="click-element">
<div id="focus-container">
<div id="focus-action-element" (focus)="onFocus()">
<button id="focus-target-element">Focus Button</button>
</div>
</div>
</div>
`,
})
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: `
<div id="top" (click)="onClick()">
<div id="bottom" (click)="onClick()"></div>
</div>
`,
})
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: `
<div id="top" (click)="onClick($event)">
<div id="bottom" (click)="onClick($event)"></div>
</div>
`,
})
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);
});
});
});

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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('<div></div>');
const child = el('<div></div>');
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('<div></div>');
const child = el('<div></div>');
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('<div></div>');
const child = el('<div></div>');
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('<div></div>');
const child = el('<div></div>');
doc.body.appendChild(element);