Skip to content

Commit a730f09

Browse files
iterianialxhub
authored andcommitted
feat(core): Add a public API to establish events to be replayed and an attribute to mark an element with an event handler. (#55356)
These will be consumed by the event-dispatch contract to replay events. The contract and the dispatcher inclusion will be in followups. PR Close #55356
1 parent b67e11a commit a730f09

File tree

13 files changed

+293
-39
lines changed

13 files changed

+293
-39
lines changed

goldens/public-api/platform-browser/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export interface HydrationFeature<FeatureKind extends HydrationFeatureKind> {
154154

155155
// @public
156156
export enum HydrationFeatureKind {
157+
// (undocumented)
158+
EventReplay = 3,
157159
// (undocumented)
158160
HttpTransferCacheOptions = 1,
159161
// (undocumented)
@@ -257,6 +259,9 @@ export const TransferState: {
257259
// @public (undocumented)
258260
export const VERSION: Version;
259261

262+
// @public
263+
export function withEventReplay(): HydrationFeature<HydrationFeatureKind.EventReplay>;
264+
260265
// @public
261266
export function withHttpTransferCacheOptions(options: HttpTransferCacheOptions): HydrationFeature<HydrationFeatureKind.HttpTransferCacheOptions>;
262267

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export {XSS_SECURITY_URL as ɵXSS_SECURITY_URL} from './error_details_base_url';
2424
export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError, RuntimeErrorCode as ɵRuntimeErrorCode} from './errors';
2525
export {annotateForHydration as ɵannotateForHydration} from './hydration/annotate';
2626
export {withDomHydration as ɵwithDomHydration, withI18nSupport as ɵwithI18nSupport} from './hydration/api';
27+
export {withEventReplay as ɵwithEventReplay} from './hydration/event_replay';
2728
export {IS_HYDRATION_DOM_REUSE_ENABLED as ɵIS_HYDRATION_DOM_REUSE_ENABLED} from './hydration/tokens';
2829
export {HydratedNode as ɵHydratedNode, HydrationInfo as ɵHydrationInfo, readHydrationInfo as ɵreadHydrationInfo, SSR_CONTENT_INTEGRITY_MARKER as ɵSSR_CONTENT_INTEGRITY_MARKER} from './hydration/utils';
2930
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';

packages/core/src/hydration/annotate.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {ApplicationRef} from '../application/application_ref';
10+
import {APP_ID} from '../application/application_tokens';
1011
import {isDetachedByI18n} from '../i18n/utils';
1112
import {ViewEncapsulation} from '../metadata';
1213
import {Renderer2} from '../render';
@@ -21,12 +22,15 @@ import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
2122
import {TransferState} from '../transfer_state';
2223

2324
import {unsupportedProjectionOfDomNodes} from './error_handling';
25+
import {collectDomEventsInfo, EVENT_REPLAY_ENABLED_DEFAULT, setJSActionAttribute} from './event_replay';
2426
import {getOrComputeI18nChildren, isI18nHydrationEnabled, isI18nHydrationSupportEnabled, trySerializeI18nBlock} from './i18n';
2527
import {CONTAINERS, DISCONNECTED_NODES, ELEMENT_CONTAINERS, I18N_DATA, MULTIPLIER, NODES, NUM_ROOT_NODES, SerializedContainerView, SerializedView, TEMPLATE_ID, TEMPLATES} from './interfaces';
2628
import {calcPathForNode, isDisconnectedNode} from './node_lookup_utils';
2729
import {isInSkipHydrationBlock, SKIP_HYDRATION_ATTR_NAME} from './skip_hydration';
30+
import {IS_EVENT_REPLAY_ENABLED} from './tokens';
2831
import {getLNodeForHydration, NGH_ATTR_NAME, NGH_DATA_KEY, processTextNodeBeforeSerialization, TextNodeMarker} from './utils';
2932

33+
3034
/**
3135
* A collection that tracks all serialized views (`ngh` DOM annotations)
3236
* to avoid duplication. An attempt to add a duplicate view results in the
@@ -84,6 +88,8 @@ export interface HydrationContext {
8488
corruptedTextNodes: Map<HTMLElement, TextNodeMarker>;
8589
isI18nHydrationEnabled: boolean;
8690
i18nChildren: Map<TView, Set<number>|null>;
91+
eventTypesToReplay: Set<string>;
92+
shouldReplayEvents: boolean;
8793
}
8894

8995
/**
@@ -160,14 +166,16 @@ function annotateLContainerForHydration(lContainer: LContainer, context: Hydrati
160166
*
161167
* @param appRef An instance of an ApplicationRef.
162168
* @param doc A reference to the current Document instance.
169+
* @return event types that need to be replayed
163170
*/
164171
export function annotateForHydration(appRef: ApplicationRef, doc: Document) {
165172
const injector = appRef.injector;
166173
const isI18nHydrationEnabledVal = isI18nHydrationEnabled(injector);
167-
168174
const serializedViewCollection = new SerializedViewCollection();
169175
const corruptedTextNodes = new Map<HTMLElement, TextNodeMarker>();
170176
const viewRefs = appRef._views;
177+
const shouldReplayEvents = injector.get(IS_EVENT_REPLAY_ENABLED, EVENT_REPLAY_ENABLED_DEFAULT);
178+
const eventTypesToReplay = new Set<string>();
171179
for (const viewRef of viewRefs) {
172180
const lNode = getLNodeForHydration(viewRef);
173181

@@ -179,6 +187,8 @@ export function annotateForHydration(appRef: ApplicationRef, doc: Document) {
179187
corruptedTextNodes,
180188
isI18nHydrationEnabled: isI18nHydrationEnabledVal,
181189
i18nChildren: new Map(),
190+
eventTypesToReplay,
191+
shouldReplayEvents,
182192
};
183193
if (isLContainer(lNode)) {
184194
annotateLContainerForHydration(lNode, context);
@@ -197,6 +207,7 @@ export function annotateForHydration(appRef: ApplicationRef, doc: Document) {
197207
const serializedViews = serializedViewCollection.getAll();
198208
const transferState = injector.get(TransferState);
199209
transferState.set(NGH_DATA_KEY, serializedViews);
210+
return eventTypesToReplay.size > 0 ? eventTypesToReplay : undefined;
200211
}
201212

202213
/**
@@ -322,10 +333,16 @@ function serializeLView(lView: LView, context: HydrationContext): SerializedView
322333
const ngh: SerializedView = {};
323334
const tView = lView[TVIEW];
324335
const i18nChildren = getOrComputeI18nChildren(tView, context);
336+
const nativeElementsToEventTypes = context.shouldReplayEvents ?
337+
collectDomEventsInfo(tView, lView, context.eventTypesToReplay) :
338+
null;
325339
// Iterate over DOM element references in an LView.
326340
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
327341
const tNode = tView.data[i] as TNode;
328342
const noOffsetIndex = i - HEADER_OFFSET;
343+
if (nativeElementsToEventTypes) {
344+
setJSActionAttribute(tNode, lView[i], nativeElementsToEventTypes);
345+
}
329346

330347
// Attempt to serialize any i18n data for the given slot. We do this first, as i18n
331348
// has its own process for serialization.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
import {Provider} from '../di/interface/provider';
10+
import {TNode, TNodeType} from '../render3/interfaces/node';
11+
import {RNode} from '../render3/interfaces/renderer_dom';
12+
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
13+
import {unwrapRNode} from '../render3/util/view_utils';
14+
15+
import {IS_EVENT_REPLAY_ENABLED} from './tokens';
16+
17+
export const EVENT_REPLAY_ENABLED_DEFAULT = false;
18+
19+
/**
20+
* Returns a set of providers required to setup support for event replay.
21+
* Requires hydration to be enabled separately.
22+
*/
23+
export function withEventReplay(): Provider[] {
24+
return [
25+
{
26+
provide: IS_EVENT_REPLAY_ENABLED,
27+
useValue: true,
28+
},
29+
];
30+
}
31+
32+
/**
33+
* Extracts information about all DOM events (added in a template) registered on elements in a give
34+
* LView. Maps collected events to a corresponding DOM element (an element is used as a key).
35+
*/
36+
export function collectDomEventsInfo(
37+
tView: TView, lView: LView, eventTypesToReplay: Set<string>): Map<Element, string[]> {
38+
const events = new Map<Element, string[]>();
39+
const lCleanup = lView[CLEANUP];
40+
const tCleanup = tView.cleanup;
41+
if (!tCleanup || !lCleanup) {
42+
return events;
43+
}
44+
for (let i = 0; i < tCleanup.length;) {
45+
const firstParam = tCleanup[i++];
46+
const secondParam = tCleanup[i++];
47+
if (typeof firstParam !== 'string') {
48+
continue;
49+
}
50+
const name: string = firstParam;
51+
eventTypesToReplay.add(name);
52+
const listenerElement = unwrapRNode(lView[secondParam]) as any as Element;
53+
i++; // move the cursor to the next position (location of the listener idx)
54+
const useCaptureOrIndx = tCleanup[i++];
55+
// if useCaptureOrIndx is boolean then report it as is.
56+
// if useCaptureOrIndx is positive number then it in unsubscribe method
57+
// if useCaptureOrIndx is negative number then it is a Subscription
58+
const isDomEvent = typeof useCaptureOrIndx === 'boolean' || useCaptureOrIndx >= 0;
59+
if (!isDomEvent) {
60+
continue;
61+
}
62+
if (!events.has(listenerElement)) {
63+
events.set(listenerElement, [name]);
64+
} else {
65+
events.get(listenerElement)!.push(name);
66+
}
67+
}
68+
return events;
69+
}
70+
71+
export function setJSActionAttribute(
72+
tNode: TNode, rNode: RNode, nativeElementToEvents: Map<Element, string[]>) {
73+
if (tNode.type & TNodeType.Element) {
74+
const nativeElement = unwrapRNode(rNode) as Element;
75+
const events = nativeElementToEvents.get(nativeElement) ?? [];
76+
const parts = events.map(event => `${event}:`);
77+
if (parts.length > 0) {
78+
nativeElement.setAttribute('jsaction', parts.join(';'));
79+
}
80+
}
81+
}

packages/core/src/hydration/tokens.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,10 @@ export const PRESERVE_HOST_CONTENT = new InjectionToken<boolean>(
3535
*/
3636
export const IS_I18N_HYDRATION_ENABLED = new InjectionToken<boolean>(
3737
(typeof ngDevMode === 'undefined' || !!ngDevMode ? 'IS_I18N_HYDRATION_ENABLED' : ''));
38+
39+
/**
40+
* Internal token that indicates whether event replay support for SSR
41+
* is enabled.
42+
*/
43+
export const IS_EVENT_REPLAY_ENABLED = new InjectionToken<boolean>(
44+
(typeof ngDevMode === 'undefined' || !!ngDevMode ? 'IS_EVENT_REPLAY_ENABLED' : ''));

packages/core/test/bundling/defer/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,6 +1370,9 @@
13701370
{
13711371
"name": "init_event_emitter"
13721372
},
1373+
{
1374+
"name": "init_event_replay"
1375+
},
13731376
{
13741377
"name": "init_fields"
13751378
},

packages/platform-browser/src/hydration.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {HttpTransferCacheOptions, ɵwithHttpTransferCache} from '@angular/common/http';
10-
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, makeEnvironmentProviders, NgZone, Provider, ɵConsole as Console, ɵformatRuntimeError as formatRuntimeError, ɵwithDomHydration as withDomHydration, ɵwithI18nSupport} from '@angular/core';
10+
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, makeEnvironmentProviders, NgZone, Provider, ɵConsole as Console, ɵformatRuntimeError as formatRuntimeError, ɵwithDomHydration as withDomHydration, ɵwithEventReplay, ɵwithI18nSupport} from '@angular/core';
1111

1212
import {RuntimeErrorCode} from './errors';
1313

@@ -21,6 +21,7 @@ export enum HydrationFeatureKind {
2121
NoHttpTransferCache,
2222
HttpTransferCacheOptions,
2323
I18nSupport,
24+
EventReplay,
2425
}
2526

2627
/**
@@ -81,6 +82,16 @@ export function withI18nSupport(): HydrationFeature<HydrationFeatureKind.I18nSup
8182
return hydrationFeature(HydrationFeatureKind.I18nSupport, ɵwithI18nSupport());
8283
}
8384

85+
/**
86+
* Enables support for event replay
87+
*
88+
* @developerPreview
89+
* @publicApi
90+
*/
91+
export function withEventReplay(): HydrationFeature<HydrationFeatureKind.EventReplay> {
92+
return hydrationFeature(HydrationFeatureKind.EventReplay, ɵwithEventReplay());
93+
}
94+
8495
/**
8596
* Returns an `ENVIRONMENT_INITIALIZER` token setup with a function
8697
* that verifies whether compatible ZoneJS was used in an application

packages/platform-browser/src/platform-browser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export {REMOVE_STYLES_ON_COMPONENT_DESTROY} from './dom/dom_renderer';
7878
export {EVENT_MANAGER_PLUGINS, EventManager, EventManagerPlugin} from './dom/events/event_manager';
7979
export {HAMMER_GESTURE_CONFIG, HAMMER_LOADER, HammerGestureConfig, HammerLoader, HammerModule} from './dom/events/hammer_gestures';
8080
export {DomSanitizer, SafeHtml, SafeResourceUrl, SafeScript, SafeStyle, SafeUrl, SafeValue} from './security/dom_sanitization_service';
81-
export {HydrationFeature, HydrationFeatureKind, provideClientHydration, withHttpTransferCacheOptions, withI18nSupport, withNoHttpTransferCache} from './hydration';
81+
export {HydrationFeature, HydrationFeatureKind, provideClientHydration, withEventReplay, withHttpTransferCacheOptions, withI18nSupport, withNoHttpTransferCache} from './hydration';
8282

8383
export * from './private_export';
8484
export {VERSION} from './version';

packages/platform-server/src/transfer_state.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export const TRANSFER_STATE_SERIALIZATION_PROVIDERS: Provider[] = [
2020
},
2121
];
2222

23+
/** TODO: Move this to a utils folder and convert to use SafeValues. */
24+
export function createScript(doc: Document, textContent: string) {
25+
const script = doc.createElement('script');
26+
script.textContent = textContent;
27+
return script;
28+
}
29+
2330
function serializeTransferStateFactory(doc: Document, appId: string, transferStore: TransferState) {
2431
return () => {
2532
// The `.toJSON` here causes the `onSerialize` callbacks to be called.
@@ -32,10 +39,9 @@ function serializeTransferStateFactory(doc: Document, appId: string, transferSto
3239
return;
3340
}
3441

35-
const script = doc.createElement('script');
42+
const script = createScript(doc, content);
3643
script.id = appId + '-state';
3744
script.setAttribute('type', 'application/json');
38-
script.textContent = content;
3945

4046
// It is intentional that we add the script at the very bottom. Angular CLI script tags for
4147
// bundles are always `type="module"`. These are deferred by default and cause the transfer

packages/platform-server/src/utils.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ApplicationRef, InjectionToken, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵannotateForHydration as annotateForHydration, ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER, ɵwhenStable as whenStable} from '@angular/core';
9+
import {APP_ID, ApplicationRef, InjectionToken, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵannotateForHydration as annotateForHydration, ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER, ɵwhenStable as whenStable} from '@angular/core';
1010

1111
import {PlatformState} from './platform_state';
1212
import {platformServer} from './server';
1313
import {BEFORE_APP_SERIALIZED, INITIAL_CONFIG} from './tokens';
14+
import {createScript} from './transfer_state';
1415

1516
interface PlatformOptions {
1617
document?: string|Document;
@@ -59,6 +60,16 @@ function appendServerContextInfo(applicationRef: ApplicationRef) {
5960
});
6061
}
6162

63+
function insertEventRecordScript(
64+
appId: string, doc: Document, eventTypesToBeReplayed: Set<string>) {
65+
const events = Array.from(eventTypesToBeReplayed);
66+
// This is defined in packages/core/primitives/event-dispatch/contract_binary.ts
67+
const replayScript = `window.__jsaction_bootstrap('ngContracts', document.body, ${
68+
JSON.stringify(appId)}, ${JSON.stringify(events)});`;
69+
const script = createScript(doc, replayScript);
70+
doc.body.insertBefore(script, doc.body.firstChild);
71+
}
72+
6273
async function _render(platformRef: PlatformRef, applicationRef: ApplicationRef): Promise<string> {
6374
const environmentInjector = applicationRef.injector;
6475

@@ -69,7 +80,10 @@ async function _render(platformRef: PlatformRef, applicationRef: ApplicationRef)
6980
if (applicationRef.injector.get(IS_HYDRATION_DOM_REUSE_ENABLED, false)) {
7081
const doc = platformState.getDocument();
7182
appendSsrContentIntegrityMarker(doc);
72-
annotateForHydration(applicationRef, doc);
83+
const eventTypesToBeReplayed = annotateForHydration(applicationRef, doc);
84+
if (eventTypesToBeReplayed) {
85+
insertEventRecordScript(environmentInjector.get(APP_ID), doc, eventTypesToBeReplayed);
86+
}
7387
}
7488

7589
// Run any BEFORE_APP_SERIALIZED callbacks just before rendering to string.

0 commit comments

Comments
 (0)