Skip to content

Commit

Permalink
refactor(core): Replay events from the event contract using the dispa…
Browse files Browse the repository at this point in the history
…tcher. (#55467)

This should accomplish event replay during full page hydration.

PR Close #55467
  • Loading branch information
iteriani authored and AndrewKushnir committed Apr 23, 2024
1 parent 2e2ca5e commit 811fe00
Show file tree
Hide file tree
Showing 11 changed files with 604 additions and 233 deletions.
108 changes: 108 additions & 0 deletions goldens/public-api/core/primitives/event-dispatch/index.md
@@ -0,0 +1,108 @@
## API Report File for "@angular/core_primitives_event-dispatch"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts

// @public
export function bootstrapEventContract(field: string, container: Element, appId: string, events: string[], anyWindow?: any): EventContract;

// @public
export class Dispatcher {
constructor(getHandler?: ((eventInfoWrapper: EventInfoWrapper) => EventInfoHandler | void) | undefined, { stopPropagation }?: {
stopPropagation?: boolean;
});
canDispatch(eventInfoWrapper: EventInfoWrapper): boolean;
dispatch(eventInfo: EventInfo, isGlobalDispatch?: boolean): EventInfo | void;
hasAction(name: string): boolean;
registerEventInfoHandlers<T>(namespace: string, instance: T | null, methods: {
[key: string]: EventInfoHandler;
}): void;
registerGlobalHandler(eventType: string, handler: GlobalHandler): void;
setEventReplayer(eventReplayer: Replayer): void;
unregisterGlobalHandler(eventType: string, handler: GlobalHandler): void;
unregisterHandler(namespace: string, name: string): void;
}

// @public
export class EventContract implements UnrenamedEventContract {
constructor(containerManager: EventContractContainerManager, stopPropagation?: false);
// (undocumented)
static A11Y_CLICK_SUPPORT: boolean;
// (undocumented)
static A11Y_SUPPORT_IN_DISPATCHER: boolean;
addA11yClickSupport(): void;
addEvent(eventType: string, prefixedEventType?: string): void;
cleanUp(): void;
// (undocumented)
static CUSTOM_EVENT_SUPPORT: boolean;
// (undocumented)
ecaacs?: (updateEventInfoForA11yClick: typeof a11yClickLib.updateEventInfoForA11yClick, preventDefaultForA11yClick: typeof a11yClickLib.preventDefaultForA11yClick, populateClickOnlyAction: typeof a11yClickLib.populateClickOnlyAction) => void;
ecrd(dispatcher: Dispatcher_2, restriction: Restriction): void;
exportAddA11yClickSupport(): void;
handler(eventType: string): EventHandler | undefined;
// (undocumented)
static JSNAMESPACE_SUPPORT: boolean;
// (undocumented)
static MOUSE_SPECIAL_SUPPORT: boolean;
registerDispatcher(dispatcher: Dispatcher_2, restriction: Restriction): void;
replayEarlyEvents(): void;
// (undocumented)
static STOP_PROPAGATION: boolean;
}

// @public
export class EventContractContainer implements EventContractContainerManager {
constructor(element: Element);
addEventListener(eventType: string, getHandler: (element: Element) => (event: Event) => void): void;
cleanUp(): void;
// (undocumented)
readonly element: Element;
}

// @public
export class EventInfoWrapper {
constructor(eventInfo: EventInfo);
// (undocumented)
clone(): EventInfoWrapper;
// (undocumented)
readonly eventInfo: EventInfo;
// (undocumented)
getAction(): {
name: string;
element: Element;
} | undefined;
// (undocumented)
getContainer(): Element;
// (undocumented)
getEvent(): Event;
// (undocumented)
getEventType(): string;
// (undocumented)
getIsReplay(): boolean | undefined;
// (undocumented)
getTargetElement(): Element;
// (undocumented)
getTimestamp(): number;
// (undocumented)
setAction(action: ActionInfo | undefined): void;
// (undocumented)
setContainer(container: Element): void;
// (undocumented)
setEvent(event: Event): void;
// (undocumented)
setEventType(eventType: string): void;
// (undocumented)
setIsReplay(replay: boolean): void;
// (undocumented)
setTargetElement(targetElement: Element): void;
// (undocumented)
setTimestamp(timestamp: number): void;
}

// @public
export function registerDispatcher(eventContract: UnrenamedEventContract, dispatcher: Dispatcher): void;

// (No @packageDocumentation comment for this package)

```
6 changes: 6 additions & 0 deletions packages/bazel/test/ng_package/core_package.spec.ts
Expand Up @@ -67,6 +67,12 @@ describe('@angular/core ng_package', () => {
esm: './esm2022/core.mjs',
default: './fesm2022/core.mjs',
},
'./primitives/event-dispatch': {
types: './primitives/event-dispatch/index.d.ts',
esm2022: './esm2022/primitives/event-dispatch/index.mjs',
esm: './esm2022/primitives/event-dispatch/index.mjs',
default: './fesm2022/primitives/event-dispatch.mjs',
},
'./primitives/signals': {
types: './primitives/signals/index.d.ts',
esm2022: './esm2022/primitives/signals/index.mjs',
Expand Down
2 changes: 2 additions & 0 deletions packages/core/BUILD.bazel
Expand Up @@ -30,6 +30,7 @@ ng_module(
),
deps = [
"//packages:types",
"//packages/core/primitives/event-dispatch",
"//packages/core/primitives/signals",
"//packages/core/src/compiler",
"//packages/core/src/di/interface",
Expand Down Expand Up @@ -78,6 +79,7 @@ ng_package(
],
deps = [
":core",
"//packages/core/primitives/event-dispatch",
"//packages/core/primitives/signals",
"//packages/core/rxjs-interop",
"//packages/core/testing",
Expand Down
120 changes: 119 additions & 1 deletion packages/core/src/hydration/event_replay.ts
Expand Up @@ -6,15 +6,28 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Dispatcher, EventContract, EventInfoWrapper, registerDispatcher} from '@angular/core/primitives/event-dispatch';

import {APP_BOOTSTRAP_LISTENER, ApplicationRef, whenStable} from '../application/application_ref';
import {APP_ID} from '../application/application_tokens';
import {Injector} from '../di';
import {inject} from '../di/injector_compatibility';
import {Provider} from '../di/interface/provider';
import {attachLViewId, readLView} from '../render3/context_discovery';
import {TNode, TNodeType} from '../render3/interfaces/node';
import {RNode} from '../render3/interfaces/renderer_dom';
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
import {CLEANUP, LView, TVIEW, TView} from '../render3/interfaces/view';
import {isPlatformBrowser} from '../render3/util/misc_utils';
import {unwrapRNode} from '../render3/util/view_utils';

import {IS_EVENT_REPLAY_ENABLED} from './tokens';

export const EVENT_REPLAY_ENABLED_DEFAULT = false;
export const CONTRACT_PROPERTY = 'ngContracts';

declare global {
var ngContracts: {[key: string]: EventContract};
}

/**
* Returns a set of providers required to setup support for event replay.
Expand All @@ -26,6 +39,33 @@ export function withEventReplay(): Provider[] {
provide: IS_EVENT_REPLAY_ENABLED,
useValue: true,
},
{
provide: APP_BOOTSTRAP_LISTENER,
useFactory: () => {
if (isPlatformBrowser()) {
const injector = inject(Injector);
const appRef = inject(ApplicationRef);
return () => {
// Kick off event replay logic once hydration for the initial part
// of the application is completed. This timing is similar to the unclaimed
// dehydrated views cleanup timing.
whenStable(appRef).then(() => {
const appId = injector.get(APP_ID);
// This is set in packages/platform-server/src/utils.ts
const eventContract = globalThis[CONTRACT_PROPERTY][appId] as EventContract;
if (eventContract) {
const dispatcher = new Dispatcher();
setEventReplayer(dispatcher);
// Event replay is kicked off as a side-effect of executing this function.
registerDispatcher(eventContract, dispatcher);
}
});
};
}
return () => {}; // noop for the server code
},
multi: true,
}
];
}

Expand Down Expand Up @@ -79,3 +119,81 @@ export function setJSActionAttribute(
}
}
}

/**
* Registers a function that should be invoked to replay events.
*/
function setEventReplayer(dispatcher: Dispatcher) {
dispatcher.setEventReplayer(queue => {
for (const event of queue) {
handleEvent(event);
}
});
}

/**
* Finds an LView that a given DOM element belongs to.
*/
function getLViewByElement(target: HTMLElement): LView|null {
let lView = readLView(target);
if (lView) {
return lView;
} else {
// If this node doesn't have LView info attached, then we need to
// traverse upwards up the DOM to find the nearest element that
// has already been monkey patched with data.
let parent = target as HTMLElement;
while (parent = parent.parentNode as HTMLElement) {
lView = readLView(parent);
if (lView) {
// To prevent additional lookups, monkey-patch LView id onto this DOM node.
// TODO: consider patching all parent nodes that didn't have LView id, so that
// we can avoid lookups for more nodes.
attachLViewId(target, lView);
return lView;
}
}
}
return null;
}

function handleEvent(event: EventInfoWrapper) {
const nativeElement = event.getAction()!.element;
// Dispatch event via Angular's logic
if (nativeElement) {
const lView = getLViewByElement(nativeElement as HTMLElement);
if (lView !== null) {
const tView = lView[TVIEW];
const eventName = event.getEventType();
const origEvent = event.getEvent();
const listeners = getEventListeners(tView, lView, nativeElement, eventName);
for (const listener of listeners) {
listener(origEvent);
}
}
}
}

type Listener = ((value: Event) => unknown)|(() => unknown);

function getEventListeners(
tView: TView, lView: LView, nativeElement: Element, eventName: string): Listener[] {
const listeners: Listener[] = [];
const lCleanup = lView[CLEANUP];
const tCleanup = tView.cleanup;
if (tCleanup && lCleanup) {
for (let i = 0; i < tCleanup.length;) {
const storedEventName = tCleanup[i++];
const nativeElementIndex = tCleanup[i++];
if (typeof storedEventName === 'string') {
const listenerElement = unwrapRNode(lView[nativeElementIndex]) as any as Element;
const listener: Listener = lCleanup[tCleanup[i++]];
i++; // increment to the next position;
if (listenerElement === nativeElement && eventName === storedEventName) {
listeners.push(listener);
}
}
}
}
return listeners;
}
16 changes: 16 additions & 0 deletions packages/core/src/render3/context_discovery.ts
Expand Up @@ -169,6 +169,22 @@ export function getComponentViewByInstance(componentInstance: {}): LView {
*/
const MONKEY_PATCH_KEY_NAME = '__ngContext__';

export function attachLViewId(target: any, data: LView) {
target[MONKEY_PATCH_KEY_NAME] = data[ID];
}

/**
* Returns the monkey-patch value data present on the target (which could be
* a component, directive or a DOM node).
*/
export function readLView(target: any): LView|null {
const data = readPatchedData(target);
if (isLView(data)) {
return data;
}
return data ? data.lView : null;
}

/**
* Assigns the given data to the given target (which could be a component,
* directive or DOM node instance) using monkey-patching.
Expand Down

0 comments on commit 811fe00

Please sign in to comment.