Skip to content

Commit 6e89ef1

Browse files
iterianithePunderWoman
authored andcommitted
refactor(core): Refactor parts of event_replay into a shared library that will be used with global event delegation. (angular#56172)
This also moves the code that stashes the jsaction more closely to the code that actually sets the event listener. PR Close angular#56172
1 parent 4766233 commit 6e89ef1

File tree

11 files changed

+207
-110
lines changed

11 files changed

+207
-110
lines changed

goldens/public-api/core/primitives/event-dispatch/index.api.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44
55
```ts
66

7+
// @public (undocumented)
8+
export const Attribute: {
9+
JSACTION: string;
10+
OI: string;
11+
VED: string;
12+
VET: string;
13+
JSINSTANCE: string;
14+
JSTRACK: string;
15+
};
16+
717
// @public
818
export function bootstrapEarlyEventContract(field: string, container: HTMLElement, appId: string, eventTypes?: string[], captureEventTypes?: string[], earlyJsactionTracker?: EventContractTracker<EarlyJsactionDataContainer>): void;
919

@@ -109,9 +119,27 @@ export const isCaptureEvent: (eventType: string) => boolean;
109119
// @public
110120
export const isSupportedEvent: (eventType: string) => boolean;
111121

122+
// @public
123+
export const JSACTION = "jsaction";
124+
125+
// @public
126+
export const JSINSTANCE = "jsinstance";
127+
128+
// @public
129+
export const JSTRACK = "jstrack";
130+
131+
// @public
132+
export const OI = "oi";
133+
112134
// @public
113135
export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: EventDispatcher): void;
114136

137+
// @public
138+
export const VED = "ved";
139+
140+
// @public
141+
export const VET = "vet";
142+
115143
// (No @packageDocumentation comment for this package)
116144

117145
```

packages/core/primitives/event-dispatch/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export {bootstrapEarlyEventContract} from './src/register_events';
1515
export type {EventContractTracker} from './src/register_events';
1616
export {EventInfoWrapper} from './src/event_info';
1717
export {isSupportedEvent, isCaptureEvent} from './src/event_type';
18+
export * from './src/attribute';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
// tslint:disable:no-duplicate-imports
10+
import {
11+
EventContract,
12+
EventContractContainer,
13+
EventDispatcher,
14+
registerDispatcher,
15+
} from '@angular/core/primitives/event-dispatch';
16+
import * as Attributes from '@angular/core/primitives/event-dispatch';
17+
import {Injectable, Injector} from './di';
18+
import {RElement} from './render3/interfaces/renderer_dom';
19+
import {EVENT_REPLAY_ENABLED_DEFAULT, IS_EVENT_REPLAY_ENABLED} from './hydration/tokens';
20+
21+
declare global {
22+
interface Element {
23+
__jsaction_fns: Map<string, Function[]> | undefined;
24+
}
25+
}
26+
27+
export function invokeRegisteredListeners(event: Event) {
28+
const handlerFns = (event.currentTarget as Element)?.__jsaction_fns?.get(event.type);
29+
if (!handlerFns) {
30+
return;
31+
}
32+
for (const handler of handlerFns) {
33+
handler(event);
34+
}
35+
}
36+
37+
export function setJSActionAttribute(nativeElement: Element, eventTypes: string[]) {
38+
if (!eventTypes.length) {
39+
return;
40+
}
41+
const parts = eventTypes.reduce((prev, curr) => prev + curr + ':;', '');
42+
const existingAttr = nativeElement.getAttribute(Attributes.JSACTION);
43+
// This is required to be a module accessor to appease security tests on setAttribute.
44+
nativeElement.setAttribute(Attributes.JSACTION, `${existingAttr ?? ''}${parts}`);
45+
}
46+
47+
export const sharedStashFunction = (rEl: RElement, eventType: string, listenerFn: () => void) => {
48+
const el = rEl as unknown as Element;
49+
const eventListenerMap = el.__jsaction_fns ?? new Map();
50+
const eventListeners = eventListenerMap.get(eventType) ?? [];
51+
eventListeners.push(listenerFn);
52+
eventListenerMap.set(eventType, eventListeners);
53+
el.__jsaction_fns = eventListenerMap;
54+
};
55+
56+
export const removeListeners = (el: Element) => {
57+
el.removeAttribute(Attributes.JSACTION);
58+
el.__jsaction_fns = undefined;
59+
};
60+
61+
@Injectable({providedIn: 'root'})
62+
export class GlobalEventDelegation {
63+
eventContract!: EventContract;
64+
addEvent(el: Element, eventName: string) {
65+
if (this.eventContract) {
66+
this.eventContract.addEvent(eventName);
67+
setJSActionAttribute(el, [eventName]);
68+
return true;
69+
}
70+
return false;
71+
}
72+
}
73+
74+
export const initGlobalEventDelegation = (
75+
eventDelegation: GlobalEventDelegation,
76+
injector: Injector,
77+
) => {
78+
if (injector.get(IS_EVENT_REPLAY_ENABLED, EVENT_REPLAY_ENABLED_DEFAULT)) {
79+
return;
80+
}
81+
eventDelegation.eventContract = new EventContract(new EventContractContainer(document.body));
82+
const dispatcher = new EventDispatcher(invokeRegisteredListeners);
83+
registerDispatcher(eventDelegation.eventContract, dispatcher);
84+
};

packages/core/src/hydration/annotate.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,8 @@ import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
3838
import {TransferState} from '../transfer_state';
3939

4040
import {unsupportedProjectionOfDomNodes} from './error_handling';
41-
import {
42-
collectDomEventsInfo,
43-
EVENT_REPLAY_ENABLED_DEFAULT,
44-
setJSActionAttribute,
45-
} from './event_replay';
41+
import {collectDomEventsInfo} from './event_replay';
42+
import {setJSActionAttribute} from '../event_delegation_utils';
4643
import {
4744
getOrComputeI18nChildren,
4845
isI18nHydrationEnabled,
@@ -64,7 +61,7 @@ import {
6461
} from './interfaces';
6562
import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils';
6663
import {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration';
67-
import {IS_EVENT_REPLAY_ENABLED} from './tokens';
64+
import {EVENT_REPLAY_ENABLED_DEFAULT, IS_EVENT_REPLAY_ENABLED} from './tokens';
6865
import {
6966
getLNodeForHydration,
7067
NGH_ATTR_NAME,
@@ -440,10 +437,13 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
440437
continue;
441438
}
442439

443-
if (nativeElementsToEventTypes) {
444-
// Attach `jsaction` attribute to elements that have registered listeners,
445-
// thus potentially having a need to do an event replay.
446-
setJSActionAttribute(tNode, lView[i], nativeElementsToEventTypes);
440+
// Attach `jsaction` attribute to elements that have registered listeners,
441+
// thus potentially having a need to do an event replay.
442+
if (nativeElementsToEventTypes && tNode.type & TNodeType.Element) {
443+
const nativeElement = unwrapRNode(lView[i]) as Element;
444+
if (nativeElementsToEventTypes.has(nativeElement)) {
445+
setJSActionAttribute(nativeElement, nativeElementsToEventTypes.get(nativeElement)!);
446+
}
447447
}
448448

449449
if (Array.isArray(tNode.projection)) {

packages/core/src/hydration/event_replay.ts

Lines changed: 61 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -7,51 +7,49 @@
77
*/
88

99
import {
10-
EventDispatcher,
11-
EarlyJsactionDataContainer,
12-
EventContract,
13-
EventContractContainer,
14-
registerDispatcher,
1510
isSupportedEvent,
1611
isCaptureEvent,
12+
EventContractContainer,
13+
EventContract,
14+
EventDispatcher,
15+
registerDispatcher,
16+
EarlyJsactionDataContainer,
1717
} from '@angular/core/primitives/event-dispatch';
1818

1919
import {APP_BOOTSTRAP_LISTENER, ApplicationRef, whenStable} from '../application/application_ref';
20-
import {APP_ID} from '../application/application_tokens';
2120
import {ENVIRONMENT_INITIALIZER, Injector} from '../di';
2221
import {inject} from '../di/injector_compatibility';
2322
import {Provider} from '../di/interface/provider';
24-
import {setDisableEventReplayImpl} from '../render3/instructions/listener';
25-
import {TNode, TNodeType} from '../render3/interfaces/node';
26-
import {RElement, RNode} from '../render3/interfaces/renderer_dom';
23+
import {setStashFn} from '../render3/instructions/listener';
24+
import {RElement} from '../render3/interfaces/renderer_dom';
2725
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
2826
import {isPlatformBrowser} from '../render3/util/misc_utils';
2927
import {unwrapRNode} from '../render3/util/view_utils';
3028

31-
import {IS_EVENT_REPLAY_ENABLED} from './tokens';
32-
33-
export const EVENT_REPLAY_ENABLED_DEFAULT = false;
34-
export const CONTRACT_PROPERTY = 'ngContracts';
29+
import {IS_EVENT_REPLAY_ENABLED, IS_GLOBAL_EVENT_DELEGATION_ENABLED} from './tokens';
30+
import {
31+
GlobalEventDelegation,
32+
sharedStashFunction,
33+
removeListeners,
34+
invokeRegisteredListeners,
35+
} from '../event_delegation_utils';
36+
import {APP_ID} from '../application/application_tokens';
3537

3638
declare global {
3739
var ngContracts: {[key: string]: EarlyJsactionDataContainer};
38-
interface Element {
39-
__jsaction_fns: Map<string, Function[]> | undefined;
40-
}
41-
}
42-
43-
// TODO: Upstream this back into event-dispatch.
44-
function getJsactionData(container: EarlyJsactionDataContainer) {
45-
return container._ejsa;
4640
}
4741

48-
const JSACTION_ATTRIBUTE = 'jsaction';
42+
export const CONTRACT_PROPERTY = 'ngContracts';
4943

5044
/**
5145
* A set of DOM elements with `jsaction` attributes.
5246
*/
5347
const jsactionSet = new Set<Element>();
5448

49+
function isGlobalEventDelegationEnabled(injector: Injector) {
50+
return injector.get(IS_GLOBAL_EVENT_DELEGATION_ENABLED, false);
51+
}
52+
5553
/**
5654
* Returns a set of providers required to setup support for event replay.
5755
* Requires hydration to be enabled separately.
@@ -65,21 +63,13 @@ export function withEventReplay(): Provider[] {
6563
{
6664
provide: ENVIRONMENT_INITIALIZER,
6765
useValue: () => {
68-
setDisableEventReplayImpl((rEl: RElement, eventName: string, listenerFn: VoidFunction) => {
69-
if (rEl.hasAttribute(JSACTION_ATTRIBUTE)) {
70-
const el = rEl as unknown as Element;
71-
// We don't immediately remove the attribute here because
72-
// we need it for replay that happens after hydration.
73-
if (!jsactionSet.has(el)) {
74-
jsactionSet.add(el);
75-
el.__jsaction_fns = new Map();
76-
}
77-
const eventMap = el.__jsaction_fns!;
78-
if (!eventMap.has(eventName)) {
79-
eventMap.set(eventName, []);
80-
}
81-
eventMap.get(eventName)!.push(listenerFn);
82-
}
66+
const injector = inject(Injector);
67+
if (isGlobalEventDelegationEnabled(injector)) {
68+
return;
69+
}
70+
setStashFn((rEl: RElement, eventName: string, listenerFn: VoidFunction) => {
71+
sharedStashFunction(rEl, eventName, listenerFn);
72+
jsactionSet.add(rEl as unknown as Element);
8373
});
8474
},
8575
multi: true,
@@ -95,33 +85,15 @@ export function withEventReplay(): Provider[] {
9585
// of the application is completed. This timing is similar to the unclaimed
9686
// dehydrated views cleanup timing.
9787
whenStable(appRef).then(() => {
98-
const appId = injector.get(APP_ID);
99-
// This is set in packages/platform-server/src/utils.ts
100-
// Note: globalThis[CONTRACT_PROPERTY] may be undefined in case Event Replay feature
101-
// is enabled, but there are no events configured in an application.
102-
const container = globalThis[CONTRACT_PROPERTY]?.[appId];
103-
const earlyJsactionData = getJsactionData(container);
104-
if (earlyJsactionData) {
105-
const eventContract = new EventContract(
106-
new EventContractContainer(earlyJsactionData.c),
107-
);
108-
for (const et of earlyJsactionData.et) {
109-
eventContract.addEvent(et);
110-
}
111-
for (const et of earlyJsactionData.etc) {
112-
eventContract.addEvent(et);
113-
}
114-
eventContract.replayEarlyEvents(container);
115-
const dispatcher = new EventDispatcher(handleEvent);
116-
registerDispatcher(eventContract, dispatcher);
117-
for (const el of jsactionSet) {
118-
el.removeAttribute(JSACTION_ATTRIBUTE);
119-
el.__jsaction_fns = undefined;
120-
}
121-
// After hydration, we shouldn't need to do anymore work related to
122-
// event replay anymore.
123-
setDisableEventReplayImpl(() => {});
88+
if (isGlobalEventDelegationEnabled(injector)) {
89+
return;
12490
}
91+
const globalEventDelegation = injector.get(GlobalEventDelegation);
92+
initEventReplay(globalEventDelegation, injector);
93+
jsactionSet.forEach(removeListeners);
94+
// After hydration, we shouldn't need to do anymore work related to
95+
// event replay anymore.
96+
setStashFn(() => {});
12597
});
12698
};
12799
}
@@ -132,6 +104,32 @@ export function withEventReplay(): Provider[] {
132104
];
133105
}
134106

107+
// TODO: Upstream this back into event-dispatch.
108+
function getJsactionData(container: EarlyJsactionDataContainer) {
109+
return container._ejsa;
110+
}
111+
112+
const initEventReplay = (eventDelegation: GlobalEventDelegation, injector: Injector) => {
113+
const appId = injector.get(APP_ID);
114+
// This is set in packages/platform-server/src/utils.ts
115+
// Note: globalThis[CONTRACT_PROPERTY] may be undefined in case Event Replay feature
116+
// is enabled, but there are no events configured in an application.
117+
const container = globalThis[CONTRACT_PROPERTY]?.[appId];
118+
const earlyJsactionData = getJsactionData(container)!;
119+
const eventContract = (eventDelegation.eventContract = new EventContract(
120+
new EventContractContainer(earlyJsactionData.c),
121+
));
122+
for (const et of earlyJsactionData.et) {
123+
eventContract.addEvent(et);
124+
}
125+
for (const et of earlyJsactionData.etc) {
126+
eventContract.addEvent(et);
127+
}
128+
eventContract.replayEarlyEvents(container);
129+
const dispatcher = new EventDispatcher(invokeRegisteredListeners);
130+
registerDispatcher(eventContract, dispatcher);
131+
};
132+
135133
/**
136134
* Extracts information about all DOM events (added in a template) registered on elements in a give
137135
* LView. Maps collected events to a corresponding DOM element (an element is used as a key).
@@ -180,28 +178,3 @@ export function collectDomEventsInfo(
180178
}
181179
return events;
182180
}
183-
184-
export function setJSActionAttribute(
185-
tNode: TNode,
186-
rNode: RNode,
187-
nativeElementToEvents: Map<Element, string[]>,
188-
) {
189-
if (tNode.type & TNodeType.Element) {
190-
const nativeElement = unwrapRNode(rNode) as Element;
191-
const events = nativeElementToEvents.get(nativeElement) ?? [];
192-
const parts = events.map((event) => `${event}:`);
193-
if (parts.length > 0) {
194-
nativeElement.setAttribute(JSACTION_ATTRIBUTE, parts.join(';'));
195-
}
196-
}
197-
}
198-
199-
function handleEvent(event: Event) {
200-
const handlerFns = (event.currentTarget as Element)?.__jsaction_fns?.get(event.type);
201-
if (!handlerFns) {
202-
return;
203-
}
204-
for (const handler of handlerFns) {
205-
handler(event);
206-
}
207-
}

packages/core/src/hydration/tokens.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,12 @@ export const IS_I18N_HYDRATION_ENABLED = new InjectionToken<boolean>(
4747
export const IS_EVENT_REPLAY_ENABLED = new InjectionToken<boolean>(
4848
typeof ngDevMode === 'undefined' || !!ngDevMode ? 'IS_EVENT_REPLAY_ENABLED' : '',
4949
);
50+
51+
export const EVENT_REPLAY_ENABLED_DEFAULT = false;
52+
53+
/**
54+
* Internal token that indicates whether global event delegation support is enabled.
55+
*/
56+
export const IS_GLOBAL_EVENT_DELEGATION_ENABLED = new InjectionToken<boolean>(
57+
typeof ngDevMode === 'undefined' || !!ngDevMode ? 'IS_GLOBAL_EVENT_DELEGATION_ENABLED' : '',
58+
);

0 commit comments

Comments
 (0)