refactor(core): cleanup incremental hydration code (#58363)

This cleans up some minor issues with the incremental hydration implementation and should make maintaining it a little easier.

PR Close #58363
This commit is contained in:
Jessica Janiuk 2024-10-25 10:42:36 -04:00 committed by Alex Rickabaugh
parent 35d7ca55b2
commit 0f2f7ec754
10 changed files with 102 additions and 84 deletions

View file

@ -13,7 +13,7 @@ import {
PREFETCH_TRIGGER_CLEANUP_FNS,
TRIGGER_CLEANUP_FNS,
TriggerType,
UNIQUE_SSR_ID,
SSR_UNIQUE_ID,
} from './interfaces';
import {DeferBlockRegistry} from './registry';
@ -57,7 +57,7 @@ export function invokeAllTriggerCleanupFns(
// TODO(incremental-hydration): cleanup functions are invoked in multiple places
// should we centralize where cleanup functions are invoked to this registry?
if (registry !== null) {
registry.invokeCleanupFns(lDetails[UNIQUE_SSR_ID]!);
registry.invokeCleanupFns(lDetails[SSR_UNIQUE_ID]!);
}
invokeTriggerCleanupFns(TriggerType.Prefetch, lDetails);

View file

@ -79,12 +79,12 @@ import {
LOADING_AFTER_CLEANUP_FN,
NEXT_DEFER_BLOCK_STATE,
ON_COMPLETE_FNS,
SSR_STATE,
SSR_BLOCK_STATE,
STATE_IS_FROZEN_UNTIL,
TDeferBlockDetails,
TriggerType,
DeferBlock,
UNIQUE_SSR_ID,
SSR_UNIQUE_ID,
} from './interfaces';
import {onTimer, scheduleTimerTrigger} from './timer_scheduler';
import {
@ -144,7 +144,8 @@ function shouldTriggerWhenOnClient(
if (!isPlatformBrowser(injector)) {
return false;
}
const isServerRendered = lDetails[SSR_STATE] && lDetails[SSR_STATE] === DeferBlockState.Complete;
const isServerRendered =
lDetails[SSR_BLOCK_STATE] && lDetails[SSR_BLOCK_STATE] === DeferBlockState.Complete;
const hasHydrateTriggers = tDetails.hydrateTriggers && tDetails.hydrateTriggers.size > 0;
if (hasHydrateTriggers && isServerRendered && isIncrementalHydrationEnabled(injector)) {
return false;
@ -280,13 +281,12 @@ export function ɵɵdefer(
// In client-only mode, this function is a noop.
populateDehydratedViewsInLContainer(lContainer, tNode, lView);
let ssrState = null;
let uniqueId: string | null = null;
let ssrBlockState = null;
let ssrUniqueId: string | null = null;
if (lContainer[DEHYDRATED_VIEWS]?.length > 0) {
// TODO(incremental-hydration): this is a hack, we should serialize defer
const info = lContainer[DEHYDRATED_VIEWS][0].data;
uniqueId = info[DEFER_BLOCK_ID] ?? null;
ssrState = info[SERIALIZED_DEFER_BLOCK_STATE];
ssrUniqueId = info[DEFER_BLOCK_ID] ?? null;
ssrBlockState = info[SERIALIZED_DEFER_BLOCK_STATE];
}
// Init instance-specific defer details and store it.
@ -297,21 +297,21 @@ export function ɵɵdefer(
null, // LOADING_AFTER_CLEANUP_FN
null, // TRIGGER_CLEANUP_FNS
null, // PREFETCH_TRIGGER_CLEANUP_FNS
uniqueId, // UNIQUE_ID
ssrState, // SSR_STATE
ssrUniqueId, // SSR_UNIQUE_ID
ssrBlockState, // SSR_BLOCK_STATE
null, // ON_COMPLETE_FNS
null, // HYDRATE_TRIGGER_CLEANUP_FNS
];
setLDeferBlockDetails(lView, adjustedIndex, lDetails);
let registry: DeferBlockRegistry | null = null;
if (uniqueId !== null) {
if (ssrUniqueId !== null) {
// TODO(incremental-hydration): explore how we can make
// `DeferBlockRegistry` tree-shakable for client-only cases.
registry = injector.get(DeferBlockRegistry);
// Also store this defer block in the registry.
registry.add(uniqueId, {lView, tNode, lContainer});
registry.add(ssrUniqueId, {lView, tNode, lContainer});
}
const cleanupTriggersFn = () => invokeAllTriggerCleanupFns(lDetails, registry);
@ -415,7 +415,7 @@ export function ɵɵdeferHydrateWhen(rawValue: unknown) {
// state.
incrementallyHydrateFromBlockName(
injector,
getLDeferBlockDetails(lView, tNode)[UNIQUE_SSR_ID]!,
getLDeferBlockDetails(lView, tNode)[SSR_UNIQUE_ID]!,
(deferBlock: DeferBlock) => triggerAndWaitForCompletion(deferBlock),
);
}
@ -534,7 +534,7 @@ export function ɵɵdeferHydrateOnImmediate() {
} else {
incrementallyHydrateFromBlockName(
injector,
lDetails[UNIQUE_SSR_ID]!,
lDetails[SSR_UNIQUE_ID]!,
(deferBlock: DeferBlock) => triggerAndWaitForCompletion(deferBlock),
);
}
@ -643,6 +643,8 @@ export function ɵɵdeferHydrateOnHover() {
// We are on the server and SSR for defer blocks is enabled.
triggerDeferBlock(lView, tNode);
}
// The actual triggering of hydration on hover is handled by JSAction in
// event_replay.ts.
}
/**
@ -711,6 +713,8 @@ export function ɵɵdeferHydrateOnInteraction() {
// We are on the server and SSR for defer blocks is enabled.
triggerDeferBlock(lView, tNode);
}
// The actual triggering of hydration on interaction is handled by JSAction in
// event_replay.ts.
}
/**
@ -780,6 +784,8 @@ export function ɵɵdeferHydrateOnViewport() {
// We are on the server and SSR for defer blocks is enabled.
triggerDeferBlock(lView, tNode);
}
// The actual triggering of hydration on viewport happens in incremental.ts,
// since these instructions won't exist for dehydrated content.
}
/********** Helper functions **********/
@ -853,7 +859,7 @@ export function scheduleDelayedHydrating(
() =>
incrementallyHydrateFromBlockName(
injector,
lDetails[UNIQUE_SSR_ID]!,
lDetails[SSR_UNIQUE_ID]!,
(deferBlock: DeferBlock) => triggerAndWaitForCompletion(deferBlock),
),
injector,
@ -896,7 +902,7 @@ export function renderDeferBlockState(
const currentState = lDetails[DEFER_BLOCK_STATE];
const ssrState = lDetails[SSR_STATE];
const ssrState = lDetails[SSR_BLOCK_STATE];
if (ssrState !== null && newState < ssrState) {
return; // trying to render a previous state, exit
}

View file

@ -210,8 +210,8 @@ export const STATE_IS_FROZEN_UNTIL = 2;
export const LOADING_AFTER_CLEANUP_FN = 3;
export const TRIGGER_CLEANUP_FNS = 4;
export const PREFETCH_TRIGGER_CLEANUP_FNS = 5;
export const UNIQUE_SSR_ID = 6;
export const SSR_STATE = 7;
export const SSR_UNIQUE_ID = 6;
export const SSR_BLOCK_STATE = 7;
export const ON_COMPLETE_FNS = 8;
export const HYDRATE_TRIGGER_CLEANUP_FNS = 9;
@ -259,12 +259,12 @@ export interface LDeferBlockDetails extends Array<unknown> {
/**
* Unique id of this defer block assigned during SSR.
*/
[UNIQUE_SSR_ID]: string | null;
[SSR_UNIQUE_ID]: string | null;
/**
* Defer block state after SSR.
*/
[SSR_STATE]: number | null;
[SSR_BLOCK_STATE]: number | null;
/**
* A set of callbacks to be invoked once the main content is rendered.

View file

@ -9,13 +9,19 @@ import {ɵɵdefineInjectable} from '../di';
import {DeferBlock} from './interfaces';
// TODO(incremental-hydration): refactor this so that it's not used in CSR cases
/**
* The DeferBlockRegistry is used for incremental hydration purposes. It keeps
* track of the Defer Blocks that need hydration so we can effectively
* navigate up to the top dehydrated defer block and fire appropriate cleanup
* functions post hydration.
*/
export class DeferBlockRegistry {
private registry = new Map<string, DeferBlock>();
private cleanupFns = new Map<string, Function[]>();
add(blockId: string, info: any) {
add(blockId: string, info: DeferBlock) {
this.registry.set(blockId, info);
}
get(blockId: string) {
get(blockId: string): DeferBlock | null {
return this.registry.get(blockId) ?? null;
}
// TODO(incremental-hydration): we need to determine when this should be invoked
@ -38,7 +44,8 @@ export class DeferBlockRegistry {
invokeCleanupFns(blockId: string) {
// TODO(incremental-hydration): determine if we can safely remove entries from
// the cleanupFns after they've been invoked
// the cleanupFns after they've been invoked. Can we reset
// `this.cleanupFns.get(blockId)`?
const fns = this.cleanupFns.get(blockId) ?? [];
for (let fn of fns) {
fn();
@ -46,6 +53,8 @@ export class DeferBlockRegistry {
}
// Blocks that are being hydrated.
// TODO(incremental-hydration): cleanup task - we currently retain ids post hydration
// and need to determine when we can remove them.
hydrating = new Set();
/** @nocollapse */

View file

@ -11,14 +11,9 @@ import {EventContract} from '@angular/core/primitives/event-dispatch';
import {Attribute} from '@angular/core/primitives/event-dispatch';
import {InjectionToken, Injector} from './di';
import {RElement} from './render3/interfaces/renderer_dom';
import {
BLOCK_ELEMENT_MAP,
EVENT_REPLAY_ENABLED_DEFAULT,
IS_EVENT_REPLAY_ENABLED,
} from './hydration/tokens';
import {OnDestroy} from './interface/lifecycle_hooks';
import {BLOCK_ELEMENT_MAP} from './hydration/tokens';
export const BLOCKNAME_ATTRIBUTE = 'ngb';
export const DEFER_BLOCK_SSR_ID_ATTRIBUTE = 'ngb';
declare global {
interface Element {
@ -41,7 +36,9 @@ export function setJSActionAttributes(
eventTypes: string[],
parentDeferBlockId: string | null = null,
) {
if (!eventTypes.length || nativeElement.nodeType !== Node.ELEMENT_NODE) {
// jsaction attributes specifically should be applied to elements and not comment nodes.
// Comment nodes also have no setAttribute function. So this avoids errors.
if (eventTypes.length === 0 || nativeElement.nodeType !== Node.ELEMENT_NODE) {
return;
}
const existingAttr = nativeElement.getAttribute(Attribute.JSACTION);
@ -57,7 +54,7 @@ export function setJSActionAttributes(
const blockName = parentDeferBlockId ?? '';
if (blockName !== '' && parts.length > 0) {
nativeElement.setAttribute(BLOCKNAME_ATTRIBUTE, blockName);
nativeElement.setAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE, blockName);
}
}
@ -71,7 +68,7 @@ export const sharedStashFunction = (rEl: RElement, eventType: string, listenerFn
};
export const sharedMapFunction = (rEl: RElement, jsActionMap: Map<string, Set<Element>>) => {
let blockName = rEl.getAttribute(BLOCKNAME_ATTRIBUTE) ?? '';
let blockName = rEl.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE) ?? '';
const el = rEl as unknown as Element;
const blockSet = jsActionMap.get(blockName) ?? new Set<Element>();
if (!blockSet.has(el)) {
@ -94,7 +91,7 @@ export function removeListenersFromBlocks(blockNames: string[], injector: Inject
export const removeListeners = (el: Element) => {
el.removeAttribute(Attribute.JSACTION);
el.removeAttribute(BLOCKNAME_ATTRIBUTE);
el.removeAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE);
el.__jsaction_fns = undefined;
};

View file

@ -12,6 +12,7 @@ import {
DEFER_BLOCK_STATE as CURRENT_DEFER_BLOCK_STATE,
DeferBlockTrigger,
HydrateTriggerDetails,
TDeferBlockDetails,
} from '../defer/interfaces';
import {getLDeferBlockDetails, getTDeferBlockDetails} from '../defer/utils';
import {isDetachedByI18n} from '../i18n/utils';
@ -34,6 +35,7 @@ import {
CONTEXT,
HEADER_OFFSET,
HOST,
INJECTOR,
LView,
PARENT,
RENDERER,
@ -44,7 +46,11 @@ import {
import {unwrapLView, unwrapRNode} from '../render3/util/view_utils';
import {TransferState} from '../transfer_state';
import {unsupportedProjectionOfDomNodes} from './error_handling';
import {
unsupportedProjectionOfDomNodes,
validateMatchingNode,
validateNodeExists,
} from './error_handling';
import {collectDomEventsInfo, convertHydrateTriggersToJsAction} from './event_replay';
import {setJSActionAttributes} from '../event_delegation_utils';
import {
@ -183,13 +189,7 @@ function annotateComponentLViewForHydration(
// Root elements might also be annotated with the `ngSkipHydration` attribute,
// check if it's present before starting the serialization process.
if (hostElement && !(hostElement as HTMLElement).hasAttribute(SKIP_HYDRATION_ATTR_NAME)) {
return annotateHostElementForHydration(
hostElement as HTMLElement,
lView,
null,
context,
injector,
);
return annotateHostElementForHydration(hostElement as HTMLElement, lView, null, context);
}
return null;
}
@ -226,13 +226,7 @@ function annotateLContainerForHydration(
// Serialize all views within this view container.
const rootLView = lContainer[PARENT];
const rootLViewNghIndex = annotateHostElementForHydration(
hostElement,
rootLView,
null,
context,
injector,
);
const rootLViewNghIndex = annotateHostElementForHydration(hostElement, rootLView, null, context);
const renderer = componentLView[RENDERER] as Renderer2;
@ -336,7 +330,6 @@ function serializeLContainer(
lView: LView,
parentDeferBlockId: string | null,
context: HydrationContext,
injector: Injector,
): SerializedContainerView[] {
const views: SerializedContainerView[] = [];
let lastViewAsString = '';
@ -364,7 +357,7 @@ function serializeLContainer(
// The `+1` is to capture the `<app-root />` element.
numRootNodes = calcNumRootNodesInLContainer(childLView) + 1;
annotateLContainerForHydration(childLView, context, injector);
annotateLContainerForHydration(childLView, context, lView[INJECTOR]!);
const componentLView = unwrapLView(childLView[HOST]) as LView<unknown>;
@ -426,15 +419,17 @@ function serializeLContainer(
if ((node as Node).nodeType === Node.COMMENT_NODE) {
annotateDeferBlockAnchorForHydration(node as RComment, deferBlockId);
}
}
// Add JSAction attributes for root nodes that use some hydration triggers
const actionList = convertHydrateTriggersToJsAction(tDetails.hydrateTriggers);
for (let et of actionList) {
context.eventTypesToReplay.regular.add(et);
} else {
ngDevMode && validateNodeExists(node, childLView, tNode);
ngDevMode &&
validateMatchingNode(node, Node.COMMENT_NODE, null, childLView, tNode, true);
annotateDeferBlockAnchorForHydration(node as RComment, deferBlockId);
}
if (!isHydrateNeverBlock) {
annotateDeferBlockRootNodesWithJsAction(actionList, rootNodes, deferBlockId);
// Add JSAction attributes for root nodes that use some hydration triggers
annotateDeferBlockRootNodesWithJsAction(tDetails, rootNodes, deferBlockId, context);
}
// Use current block id as parent for nested routes.
@ -445,6 +440,9 @@ function serializeLContainer(
// (not at the view level).
serializedView[DEFER_BLOCK_ID] = deferBlockId;
}
// DEFER_BLOCK_STATE is used for reconciliation in hydration, both regular and incremental.
// We need to know which template is rendered when hydrating. So we serialize this state
// regardless of hydration type.
serializedView[DEFER_BLOCK_STATE] = lDetails[CURRENT_DEFER_BLOCK_STATE];
}
@ -452,7 +450,7 @@ function serializeLContainer(
// TODO(incremental-hydration): avoid copying of an object here
serializedView = {
...serializedView,
...serializeLView(lContainer[i] as LView, parentDeferBlockId, context, injector),
...serializeLView(lContainer[i] as LView, parentDeferBlockId, context),
};
}
}
@ -545,7 +543,6 @@ function serializeLView(
lView: LView,
parentDeferBlockId: string | null = null,
context: HydrationContext,
injector: Injector,
): SerializedView {
const ngh: SerializedView = {};
const tView = lView[TVIEW];
@ -670,7 +667,6 @@ function serializeLView(
hostNode as LView,
parentDeferBlockId,
context,
injector,
);
}
}
@ -682,7 +678,6 @@ function serializeLView(
lView,
parentDeferBlockId,
context,
injector,
);
} else if (Array.isArray(lView[i]) && !isLetDeclaration(tNode)) {
// This is a component, annotate the host node with an `ngh` attribute.
@ -695,7 +690,6 @@ function serializeLView(
lView[i],
parentDeferBlockId,
context,
injector,
);
}
} else {
@ -820,7 +814,6 @@ function annotateHostElementForHydration(
lView: LView,
parentDeferBlockId: string | null,
context: HydrationContext,
injector: Injector,
): number | null {
const renderer = lView[RENDERER];
if (
@ -835,7 +828,7 @@ function annotateHostElementForHydration(
renderer.setAttribute(element, SKIP_HYDRATION_ATTR_NAME, '');
return null;
} else {
const ngh = serializeLView(lView, parentDeferBlockId, context, injector);
const ngh = serializeLView(lView, parentDeferBlockId, context);
const index = context.serializedViewCollection.add(ngh);
renderer.setAttribute(element, NGH_ATTR_NAME, index.toString());
return index;
@ -848,10 +841,7 @@ function annotateHostElementForHydration(
* @param comment The Host element to be annotated
* @param deferBlockId the id of the target defer block
*/
function annotateDeferBlockAnchorForHydration(
comment: RComment,
deferBlockId: string | null,
): void {
function annotateDeferBlockAnchorForHydration(comment: RComment, deferBlockId: string): void {
comment.textContent = `ngh=${deferBlockId}`;
}
@ -890,11 +880,24 @@ function isContentProjectedNode(tNode: TNode): boolean {
return false;
}
/**
* Incremental hydration requires that any defer block root node
* with interaction or hover triggers have all of their root nodes
* trigger hydration with those events. So we need to make sure all
* the root nodes of that block have the proper jsaction attribute
* to ensure hydration is triggered, since the content is dehydrated
*/
function annotateDeferBlockRootNodesWithJsAction(
actionList: string[],
tDetails: TDeferBlockDetails,
rootNodes: any[],
parentDeferBlockId: string,
context: HydrationContext,
) {
const actionList = convertHydrateTriggersToJsAction(tDetails.hydrateTriggers);
for (let et of actionList) {
context.eventTypesToReplay.regular.add(et);
}
if (actionList.length > 0) {
const elementNodes = (rootNodes as HTMLElement[]).filter(
(rn) => rn.nodeType === Node.ELEMENT_NODE,

View file

@ -308,6 +308,7 @@ export function withI18nSupport(): Provider[] {
/**
* Returns a set of providers required to setup support for incremental hydration.
* Requires hydration to be enabled separately.
* Enabling incremental hydration also enables event replay for the entire app.
*
* @developerPreview
*/
@ -330,9 +331,10 @@ export function withIncrementalHydration(): Provider[] {
useFactory: () => {
if (isPlatformBrowser()) {
const injector = inject(Injector);
const doc = getDocument();
return () => {
bootstrapIncrementalHydration(getDocument(), injector);
appendDeferBlocksToJSActionMap(getDocument(), injector);
bootstrapIncrementalHydration(doc, injector);
appendDeferBlocksToJSActionMap(doc, injector);
};
}
return () => {}; // noop for the server code

View file

@ -24,7 +24,7 @@ import {whenStable, ApplicationRef} from '../application/application_ref';
* If there are any dehydrated `@defer` blocks found along the way,
* they are also stored and returned from the function (as a list of ids).
*/
export function findFirstKnownParentDeferBlock(deferBlockId: string, injector: Injector) {
export function findFirstHydratedParentDeferBlock(deferBlockId: string, injector: Injector) {
const deferBlockRegistry = injector.get(DeferBlockRegistry);
const transferState = injector.get(TransferState);
const deferBlockParents = transferState.get(NGH_DEFER_BLOCKS_KEY, {});
@ -69,7 +69,7 @@ async function hydrateFromBlockNameImpl(
// Make sure we don't hydrate/trigger the same thing multiple times
if (deferBlockRegistry.hydrating.has(blockName)) return {deferBlock: null, hydratedBlocks};
const {blockId, deferBlock, dehydratedBlocks} = findFirstKnownParentDeferBlock(
const {blockId, deferBlock, dehydratedBlocks} = findFirstHydratedParentDeferBlock(
blockName,
injector,
);

View file

@ -32,14 +32,14 @@ import {BLOCK_ELEMENT_MAP, EVENT_REPLAY_ENABLED_DEFAULT, IS_EVENT_REPLAY_ENABLED
import {
sharedStashFunction,
sharedMapFunction,
BLOCKNAME_ATTRIBUTE,
DEFER_BLOCK_SSR_ID_ATTRIBUTE,
EventContractDetails,
JSACTION_EVENT_CONTRACT,
removeListenersFromBlocks,
} from '../event_delegation_utils';
import {APP_ID} from '../application/application_tokens';
import {performanceMarkFeature} from '../util/performance';
import {hydrateFromBlockName, findFirstKnownParentDeferBlock} from './blocks';
import {hydrateFromBlockName, findFirstHydratedParentDeferBlock} from './blocks';
import {DeferBlock, DeferBlockTrigger, HydrateTriggerDetails} from '../defer/interfaces';
import {triggerAndWaitForCompletion} from '../defer/instructions';
import {cleanupDehydratedViews, cleanupLContainer} from './cleanup';
@ -226,7 +226,8 @@ export function invokeRegisteredReplayListeners(
event: Event,
currentTarget: Element | null,
) {
const blockName = (currentTarget && currentTarget.getAttribute(BLOCKNAME_ATTRIBUTE)) ?? '';
const blockName =
(currentTarget && currentTarget.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE)) ?? '';
if (/d\d+/.test(blockName)) {
hydrateAndInvokeBlockListeners(blockName, injector, event, currentTarget!);
} else if (event.eventPhase === EventPhase.REPLAY) {
@ -259,7 +260,7 @@ async function triggerBlockHydration(
onTriggerFn: (deferBlock: any) => void,
) {
// grab the list of dehydrated blocks and queue them up
const {dehydratedBlocks} = findFirstKnownParentDeferBlock(blockName, injector);
const {dehydratedBlocks} = findFirstHydratedParentDeferBlock(blockName, injector);
for (let block of dehydratedBlocks) {
hydratingBlocks.add(block);
}
@ -279,7 +280,7 @@ function replayQueuedBlockEvents(hydratedBlocks: Set<string>, injector: Injector
// empty it
blockEventQueue = [];
for (let {event, currentTarget} of queue) {
const blockName = currentTarget.getAttribute(BLOCKNAME_ATTRIBUTE)!;
const blockName = currentTarget.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE)!;
if (hydratedBlocks.has(blockName)) {
invokeListeners(event, currentTarget);
} else {

View file

@ -459,7 +459,10 @@
"name": "SOURCE"
},
{
"name": "SSR_STATE"
"name": "SSR_BLOCK_STATE"
},
{
"name": "SSR_UNIQUE_ID"
},
{
"name": "SVG_NAMESPACE"
@ -509,9 +512,6 @@
{
"name": "TYPE"
},
{
"name": "UNIQUE_SSR_ID"
},
{
"name": "USE_VALUE"
},