Skip to content

Commit 811fe00

Browse files
iterianiAndrewKushnir
authored andcommitted
refactor(core): Replay events from the event contract using the dispatcher. (#55467)
This should accomplish event replay during full page hydration. PR Close #55467
1 parent 2e2ca5e commit 811fe00

File tree

11 files changed

+604
-233
lines changed

11 files changed

+604
-233
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
## API Report File for "@angular/core_primitives_event-dispatch"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
// @public
8+
export function bootstrapEventContract(field: string, container: Element, appId: string, events: string[], anyWindow?: any): EventContract;
9+
10+
// @public
11+
export class Dispatcher {
12+
constructor(getHandler?: ((eventInfoWrapper: EventInfoWrapper) => EventInfoHandler | void) | undefined, { stopPropagation }?: {
13+
stopPropagation?: boolean;
14+
});
15+
canDispatch(eventInfoWrapper: EventInfoWrapper): boolean;
16+
dispatch(eventInfo: EventInfo, isGlobalDispatch?: boolean): EventInfo | void;
17+
hasAction(name: string): boolean;
18+
registerEventInfoHandlers<T>(namespace: string, instance: T | null, methods: {
19+
[key: string]: EventInfoHandler;
20+
}): void;
21+
registerGlobalHandler(eventType: string, handler: GlobalHandler): void;
22+
setEventReplayer(eventReplayer: Replayer): void;
23+
unregisterGlobalHandler(eventType: string, handler: GlobalHandler): void;
24+
unregisterHandler(namespace: string, name: string): void;
25+
}
26+
27+
// @public
28+
export class EventContract implements UnrenamedEventContract {
29+
constructor(containerManager: EventContractContainerManager, stopPropagation?: false);
30+
// (undocumented)
31+
static A11Y_CLICK_SUPPORT: boolean;
32+
// (undocumented)
33+
static A11Y_SUPPORT_IN_DISPATCHER: boolean;
34+
addA11yClickSupport(): void;
35+
addEvent(eventType: string, prefixedEventType?: string): void;
36+
cleanUp(): void;
37+
// (undocumented)
38+
static CUSTOM_EVENT_SUPPORT: boolean;
39+
// (undocumented)
40+
ecaacs?: (updateEventInfoForA11yClick: typeof a11yClickLib.updateEventInfoForA11yClick, preventDefaultForA11yClick: typeof a11yClickLib.preventDefaultForA11yClick, populateClickOnlyAction: typeof a11yClickLib.populateClickOnlyAction) => void;
41+
ecrd(dispatcher: Dispatcher_2, restriction: Restriction): void;
42+
exportAddA11yClickSupport(): void;
43+
handler(eventType: string): EventHandler | undefined;
44+
// (undocumented)
45+
static JSNAMESPACE_SUPPORT: boolean;
46+
// (undocumented)
47+
static MOUSE_SPECIAL_SUPPORT: boolean;
48+
registerDispatcher(dispatcher: Dispatcher_2, restriction: Restriction): void;
49+
replayEarlyEvents(): void;
50+
// (undocumented)
51+
static STOP_PROPAGATION: boolean;
52+
}
53+
54+
// @public
55+
export class EventContractContainer implements EventContractContainerManager {
56+
constructor(element: Element);
57+
addEventListener(eventType: string, getHandler: (element: Element) => (event: Event) => void): void;
58+
cleanUp(): void;
59+
// (undocumented)
60+
readonly element: Element;
61+
}
62+
63+
// @public
64+
export class EventInfoWrapper {
65+
constructor(eventInfo: EventInfo);
66+
// (undocumented)
67+
clone(): EventInfoWrapper;
68+
// (undocumented)
69+
readonly eventInfo: EventInfo;
70+
// (undocumented)
71+
getAction(): {
72+
name: string;
73+
element: Element;
74+
} | undefined;
75+
// (undocumented)
76+
getContainer(): Element;
77+
// (undocumented)
78+
getEvent(): Event;
79+
// (undocumented)
80+
getEventType(): string;
81+
// (undocumented)
82+
getIsReplay(): boolean | undefined;
83+
// (undocumented)
84+
getTargetElement(): Element;
85+
// (undocumented)
86+
getTimestamp(): number;
87+
// (undocumented)
88+
setAction(action: ActionInfo | undefined): void;
89+
// (undocumented)
90+
setContainer(container: Element): void;
91+
// (undocumented)
92+
setEvent(event: Event): void;
93+
// (undocumented)
94+
setEventType(eventType: string): void;
95+
// (undocumented)
96+
setIsReplay(replay: boolean): void;
97+
// (undocumented)
98+
setTargetElement(targetElement: Element): void;
99+
// (undocumented)
100+
setTimestamp(timestamp: number): void;
101+
}
102+
103+
// @public
104+
export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: Dispatcher): void;
105+
106+
// (No @packageDocumentation comment for this package)
107+
108+
```

packages/bazel/test/ng_package/core_package.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ describe('@angular/core ng_package', () => {
6767
esm: './esm2022/core.mjs',
6868
default: './fesm2022/core.mjs',
6969
},
70+
'./primitives/event-dispatch': {
71+
types: './primitives/event-dispatch/index.d.ts',
72+
esm2022: './esm2022/primitives/event-dispatch/index.mjs',
73+
esm: './esm2022/primitives/event-dispatch/index.mjs',
74+
default: './fesm2022/primitives/event-dispatch.mjs',
75+
},
7076
'./primitives/signals': {
7177
types: './primitives/signals/index.d.ts',
7278
esm2022: './esm2022/primitives/signals/index.mjs',

packages/core/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ ng_module(
3030
),
3131
deps = [
3232
"//packages:types",
33+
"//packages/core/primitives/event-dispatch",
3334
"//packages/core/primitives/signals",
3435
"//packages/core/src/compiler",
3536
"//packages/core/src/di/interface",
@@ -78,6 +79,7 @@ ng_package(
7879
],
7980
deps = [
8081
":core",
82+
"//packages/core/primitives/event-dispatch",
8183
"//packages/core/primitives/signals",
8284
"//packages/core/rxjs-interop",
8385
"//packages/core/testing",

packages/core/src/hydration/event_replay.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,28 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {Dispatcher, EventContract, EventInfoWrapper, registerDispatcher} from '@angular/core/primitives/event-dispatch';
10+
11+
import {APP_BOOTSTRAP_LISTENER, ApplicationRef, whenStable} from '../application/application_ref';
12+
import {APP_ID} from '../application/application_tokens';
13+
import {Injector} from '../di';
14+
import {inject} from '../di/injector_compatibility';
915
import {Provider} from '../di/interface/provider';
16+
import {attachLViewId, readLView} from '../render3/context_discovery';
1017
import {TNode, TNodeType} from '../render3/interfaces/node';
1118
import {RNode} from '../render3/interfaces/renderer_dom';
12-
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
19+
import {CLEANUP, LView, TVIEW, TView} from '../render3/interfaces/view';
20+
import {isPlatformBrowser} from '../render3/util/misc_utils';
1321
import {unwrapRNode} from '../render3/util/view_utils';
1422

1523
import {IS_EVENT_REPLAY_ENABLED} from './tokens';
1624

1725
export const EVENT_REPLAY_ENABLED_DEFAULT = false;
26+
export const CONTRACT_PROPERTY = 'ngContracts';
27+
28+
declare global {
29+
var ngContracts: {[key: string]: EventContract};
30+
}
1831

1932
/**
2033
* Returns a set of providers required to setup support for event replay.
@@ -26,6 +39,33 @@ export function withEventReplay(): Provider[] {
2639
provide: IS_EVENT_REPLAY_ENABLED,
2740
useValue: true,
2841
},
42+
{
43+
provide: APP_BOOTSTRAP_LISTENER,
44+
useFactory: () => {
45+
if (isPlatformBrowser()) {
46+
const injector = inject(Injector);
47+
const appRef = inject(ApplicationRef);
48+
return () => {
49+
// Kick off event replay logic once hydration for the initial part
50+
// of the application is completed. This timing is similar to the unclaimed
51+
// dehydrated views cleanup timing.
52+
whenStable(appRef).then(() => {
53+
const appId = injector.get(APP_ID);
54+
// This is set in packages/platform-server/src/utils.ts
55+
const eventContract = globalThis[CONTRACT_PROPERTY][appId] as EventContract;
56+
if (eventContract) {
57+
const dispatcher = new Dispatcher();
58+
setEventReplayer(dispatcher);
59+
// Event replay is kicked off as a side-effect of executing this function.
60+
registerDispatcher(eventContract, dispatcher);
61+
}
62+
});
63+
};
64+
}
65+
return () => {}; // noop for the server code
66+
},
67+
multi: true,
68+
}
2969
];
3070
}
3171

@@ -79,3 +119,81 @@ export function setJSActionAttribute(
79119
}
80120
}
81121
}
122+
123+
/**
124+
* Registers a function that should be invoked to replay events.
125+
*/
126+
function setEventReplayer(dispatcher: Dispatcher) {
127+
dispatcher.setEventReplayer(queue => {
128+
for (const event of queue) {
129+
handleEvent(event);
130+
}
131+
});
132+
}
133+
134+
/**
135+
* Finds an LView that a given DOM element belongs to.
136+
*/
137+
function getLViewByElement(target: HTMLElement): LView|null {
138+
let lView = readLView(target);
139+
if (lView) {
140+
return lView;
141+
} else {
142+
// If this node doesn't have LView info attached, then we need to
143+
// traverse upwards up the DOM to find the nearest element that
144+
// has already been monkey patched with data.
145+
let parent = target as HTMLElement;
146+
while (parent = parent.parentNode as HTMLElement) {
147+
lView = readLView(parent);
148+
if (lView) {
149+
// To prevent additional lookups, monkey-patch LView id onto this DOM node.
150+
// TODO: consider patching all parent nodes that didn't have LView id, so that
151+
// we can avoid lookups for more nodes.
152+
attachLViewId(target, lView);
153+
return lView;
154+
}
155+
}
156+
}
157+
return null;
158+
}
159+
160+
function handleEvent(event: EventInfoWrapper) {
161+
const nativeElement = event.getAction()!.element;
162+
// Dispatch event via Angular's logic
163+
if (nativeElement) {
164+
const lView = getLViewByElement(nativeElement as HTMLElement);
165+
if (lView !== null) {
166+
const tView = lView[TVIEW];
167+
const eventName = event.getEventType();
168+
const origEvent = event.getEvent();
169+
const listeners = getEventListeners(tView, lView, nativeElement, eventName);
170+
for (const listener of listeners) {
171+
listener(origEvent);
172+
}
173+
}
174+
}
175+
}
176+
177+
type Listener = ((value: Event) => unknown)|(() => unknown);
178+
179+
function getEventListeners(
180+
tView: TView, lView: LView, nativeElement: Element, eventName: string): Listener[] {
181+
const listeners: Listener[] = [];
182+
const lCleanup = lView[CLEANUP];
183+
const tCleanup = tView.cleanup;
184+
if (tCleanup && lCleanup) {
185+
for (let i = 0; i < tCleanup.length;) {
186+
const storedEventName = tCleanup[i++];
187+
const nativeElementIndex = tCleanup[i++];
188+
if (typeof storedEventName === 'string') {
189+
const listenerElement = unwrapRNode(lView[nativeElementIndex]) as any as Element;
190+
const listener: Listener = lCleanup[tCleanup[i++]];
191+
i++; // increment to the next position;
192+
if (listenerElement === nativeElement && eventName === storedEventName) {
193+
listeners.push(listener);
194+
}
195+
}
196+
}
197+
}
198+
return listeners;
199+
}

packages/core/src/render3/context_discovery.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,22 @@ export function getComponentViewByInstance(componentInstance: {}): LView {
169169
*/
170170
const MONKEY_PATCH_KEY_NAME = '__ngContext__';
171171

172+
export function attachLViewId(target: any, data: LView) {
173+
target[MONKEY_PATCH_KEY_NAME] = data[ID];
174+
}
175+
176+
/**
177+
* Returns the monkey-patch value data present on the target (which could be
178+
* a component, directive or a DOM node).
179+
*/
180+
export function readLView(target: any): LView|null {
181+
const data = readPatchedData(target);
182+
if (isLView(data)) {
183+
return data;
184+
}
185+
return data ? data.lView : null;
186+
}
187+
172188
/**
173189
* Assigns the given data to the given target (which could be a component,
174190
* directive or DOM node instance) using monkey-patching.

0 commit comments

Comments
 (0)