mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
3bfba7ec15
commit
40fb81fd0e
21 changed files with 454 additions and 29 deletions
|
|
@ -20,7 +20,7 @@
|
|||
},
|
||||
"forms": {
|
||||
"uncompressed": {
|
||||
"main": 171726,
|
||||
"main": 176726,
|
||||
"polyfills": 33772
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
47
packages/core/src/event_dispatch/event_delegation.ts
Normal file
47
packages/core/src/event_dispatch/event_delegation.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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)!);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -254,6 +254,9 @@
|
|||
{
|
||||
"name": "ErrorHandler"
|
||||
},
|
||||
{
|
||||
"name": "EventDelegationPlugin"
|
||||
},
|
||||
{
|
||||
"name": "EventEmitter"
|
||||
},
|
||||
|
|
@ -266,6 +269,9 @@
|
|||
{
|
||||
"name": "FALSE_BOOLEAN_VALUES"
|
||||
},
|
||||
{
|
||||
"name": "GLOBAL_EVENT_DELEGATION"
|
||||
},
|
||||
{
|
||||
"name": "GenericBrowserDomAdapter"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -161,6 +161,9 @@
|
|||
{
|
||||
"name": "ErrorHandler"
|
||||
},
|
||||
{
|
||||
"name": "EventDelegationPlugin"
|
||||
},
|
||||
{
|
||||
"name": "EventEmitter"
|
||||
},
|
||||
|
|
@ -170,6 +173,9 @@
|
|||
{
|
||||
"name": "EventManagerPlugin"
|
||||
},
|
||||
{
|
||||
"name": "GLOBAL_EVENT_DELEGATION"
|
||||
},
|
||||
{
|
||||
"name": "GenericBrowserDomAdapter"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -221,6 +221,9 @@
|
|||
{
|
||||
"name": "ErrorHandler"
|
||||
},
|
||||
{
|
||||
"name": "EventDelegationPlugin"
|
||||
},
|
||||
{
|
||||
"name": "EventEmitter"
|
||||
},
|
||||
|
|
@ -266,6 +269,9 @@
|
|||
{
|
||||
"name": "FormsExampleModule"
|
||||
},
|
||||
{
|
||||
"name": "GLOBAL_EVENT_DELEGATION"
|
||||
},
|
||||
{
|
||||
"name": "GenericBrowserDomAdapter"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -224,6 +224,9 @@
|
|||
{
|
||||
"name": "ErrorHandler"
|
||||
},
|
||||
{
|
||||
"name": "EventDelegationPlugin"
|
||||
},
|
||||
{
|
||||
"name": "EventEmitter"
|
||||
},
|
||||
|
|
@ -245,6 +248,9 @@
|
|||
{
|
||||
"name": "FormsModule"
|
||||
},
|
||||
{
|
||||
"name": "GLOBAL_EVENT_DELEGATION"
|
||||
},
|
||||
{
|
||||
"name": "GenericBrowserDomAdapter"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -164,6 +164,9 @@
|
|||
{
|
||||
"name": "ErrorHandler"
|
||||
},
|
||||
{
|
||||
"name": "EventDelegationPlugin"
|
||||
},
|
||||
{
|
||||
"name": "EventEmitter"
|
||||
},
|
||||
|
|
@ -173,6 +176,9 @@
|
|||
{
|
||||
"name": "EventManagerPlugin"
|
||||
},
|
||||
{
|
||||
"name": "GLOBAL_EVENT_DELEGATION"
|
||||
},
|
||||
{
|
||||
"name": "GenericBrowserDomAdapter"
|
||||
},
|
||||
|
|
|
|||
19
packages/core/test/event_dispatch/BUILD.bazel
Normal file
19
packages/core/test/event_dispatch/BUILD.bazel
Normal 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"],
|
||||
)
|
||||
188
packages/core/test/event_dispatch/event_dispatch_spec.ts
Normal file
188
packages/core/test/event_dispatch/event_dispatch_spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
33
packages/platform-browser/src/dom/events/event_delegation.ts
Normal file
33
packages/platform-browser/src/dom/events/event_delegation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue