angular/packages/core/src/defer/triggering.ts
kirjs 70547ebce0 refactor(core): make lView[Injector] non-nullable (#58805)
There actually no cases, where it can be null. Also update all usages.

PR Close #58805
2024-11-25 15:24:40 +00:00

642 lines
23 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {afterNextRender} from '../render3/after_render/hooks';
import {Injector} from '../di';
import {internalImportProvidersFrom} from '../di/provider_collection';
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {cleanupHydratedDeferBlocks} from '../hydration/cleanup';
import {BlockSummary, ElementTrigger, NUM_ROOT_NODES} from '../hydration/interfaces';
import {
assertSsrIdDefined,
getParentBlockHydrationQueue,
isIncrementalHydrationEnabled,
} from '../hydration/utils';
import {PendingTasksInternal} from '../pending_tasks';
import {assertLContainer} from '../render3/assert';
import {getComponentDef, getDirectiveDef, getPipeDef} from '../render3/def_getters';
import {getTemplateLocationDetails} from '../render3/instructions/element_validation';
import {handleError} from '../render3/instructions/shared';
import {DirectiveDefList, PipeDefList} from '../render3/interfaces/definition';
import {TNode} from '../render3/interfaces/node';
import {INJECTOR, LView, TView, TVIEW} from '../render3/interfaces/view';
import {getCurrentTNode, getLView} from '../render3/state';
import {throwError} from '../util/assert';
import {
invokeAllTriggerCleanupFns,
invokeTriggerCleanupFns,
storeTriggerCleanupFn,
} from './cleanup';
import {onViewport} from './dom_triggers';
import {onIdle} from './idle_scheduler';
import {
DeferBlockBehavior,
DeferBlockState,
DeferBlockTrigger,
DeferDependenciesLoadingState,
LDeferBlockDetails,
ON_COMPLETE_FNS,
SSR_BLOCK_STATE,
SSR_UNIQUE_ID,
TDeferBlockDetails,
TriggerType,
} from './interfaces';
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from './registry';
import {
DEFER_BLOCK_CONFIG,
DEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
renderDeferBlockState,
renderDeferStateAfterResourceLoading,
renderPlaceholder,
} from './rendering';
import {onTimer} from './timer_scheduler';
import {
addDepsToRegistry,
assertDeferredDependenciesLoaded,
getLDeferBlockDetails,
getPrimaryBlockTNode,
getTDeferBlockDetails,
} from './utils';
import {ApplicationRef} from '../application/application_ref';
/**
* Schedules triggering of a defer block for `on idle` and `on timer` conditions.
*/
export function scheduleDelayedTrigger(
scheduleFn: (callback: VoidFunction, injector: Injector) => VoidFunction,
) {
const lView = getLView();
const tNode = getCurrentTNode()!;
const injector = lView[INJECTOR];
const lDetails = getLDeferBlockDetails(lView, tNode);
const tDetails = getTDeferBlockDetails(lView[TVIEW], tNode);
renderPlaceholder(lView, tNode);
if (shouldTriggerWhenOnClient(lView[INJECTOR], lDetails, tDetails)) {
// Only trigger the scheduled trigger on the browser
// since we don't want to delay the server response.
const cleanupFn = scheduleFn(() => triggerDeferBlock(lView, tNode), injector);
storeTriggerCleanupFn(TriggerType.Regular, lDetails, cleanupFn);
}
}
/**
* Schedules prefetching for `on idle` and `on timer` triggers.
*
* @param scheduleFn A function that does the scheduling.
*/
export function scheduleDelayedPrefetching(
scheduleFn: (callback: VoidFunction, injector: Injector) => VoidFunction,
trigger: DeferBlockTrigger,
) {
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
return;
}
const lView = getLView();
const injector = lView[INJECTOR];
// Only trigger the scheduled trigger on the browser
// since we don't want to delay the server response.
const tNode = getCurrentTNode()!;
const tView = lView[TVIEW];
const tDetails = getTDeferBlockDetails(tView, tNode);
const prefetchTriggers = getPrefetchTriggers(tDetails);
prefetchTriggers.add(trigger);
if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) {
const lDetails = getLDeferBlockDetails(lView, tNode);
const prefetch = () => triggerPrefetching(tDetails, lView, tNode);
const cleanupFn = scheduleFn(prefetch, injector);
storeTriggerCleanupFn(TriggerType.Prefetch, lDetails, cleanupFn);
}
}
/**
* Schedules hydration triggering of a defer block for `on idle` and `on timer` conditions.
*/
export function scheduleDelayedHydrating(
scheduleFn: (callback: VoidFunction, injector: Injector) => VoidFunction,
lView: LView,
tNode: TNode,
) {
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
return;
}
// Only trigger the scheduled trigger on the browser
// since we don't want to delay the server response.
const injector = lView[INJECTOR];
const lDetails = getLDeferBlockDetails(lView, tNode);
const ssrUniqueId = lDetails[SSR_UNIQUE_ID]!;
ngDevMode && assertSsrIdDefined(ssrUniqueId);
const cleanupFn = scheduleFn(
() => triggerHydrationFromBlockName(injector, ssrUniqueId),
injector,
);
storeTriggerCleanupFn(TriggerType.Hydrate, lDetails, cleanupFn);
}
/**
* Trigger prefetching of dependencies for a defer block.
*
* @param tDetails Static information about this defer block.
* @param lView LView of a host view.
*/
export function triggerPrefetching(tDetails: TDeferBlockDetails, lView: LView, tNode: TNode) {
const tDeferBlockDetails = getTDeferBlockDetails(lView[TVIEW], tNode);
if (lView[INJECTOR] && shouldTriggerDeferBlock(lView[INJECTOR], tDeferBlockDetails)) {
triggerResourceLoading(tDetails, lView, tNode);
}
}
/**
* Trigger loading of defer block dependencies if the process hasn't started yet.
*
* @param tDetails Static information about this defer block.
* @param lView LView of a host view.
*/
export function triggerResourceLoading(
tDetails: TDeferBlockDetails,
lView: LView,
tNode: TNode,
): Promise<unknown> {
const injector = lView[INJECTOR];
const tView = lView[TVIEW];
if (tDetails.loadingState !== DeferDependenciesLoadingState.NOT_STARTED) {
// If the loading status is different from initial one, it means that
// the loading of dependencies is in progress and there is nothing to do
// in this function. All details can be obtained from the `tDetails` object.
return tDetails.loadingPromise ?? Promise.resolve();
}
const lDetails = getLDeferBlockDetails(lView, tNode);
const primaryBlockTNode = getPrimaryBlockTNode(tView, tDetails);
// Switch from NOT_STARTED -> IN_PROGRESS state.
tDetails.loadingState = DeferDependenciesLoadingState.IN_PROGRESS;
// Prefetching is triggered, cleanup all registered prefetch triggers.
invokeTriggerCleanupFns(TriggerType.Prefetch, lDetails);
let dependenciesFn = tDetails.dependencyResolverFn;
if (ngDevMode) {
// Check if dependency function interceptor is configured.
const deferDependencyInterceptor = injector.get(DEFER_BLOCK_DEPENDENCY_INTERCEPTOR, null, {
optional: true,
});
if (deferDependencyInterceptor) {
dependenciesFn = deferDependencyInterceptor.intercept(dependenciesFn);
}
}
// Indicate that an application is not stable and has a pending task.
const pendingTasks = injector.get(PendingTasksInternal);
const taskId = pendingTasks.add();
// The `dependenciesFn` might be `null` when all dependencies within
// a given defer block were eagerly referenced elsewhere in a file,
// thus no dynamic `import()`s were produced.
if (!dependenciesFn) {
tDetails.loadingPromise = Promise.resolve().then(() => {
tDetails.loadingPromise = null;
tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE;
pendingTasks.remove(taskId);
});
return tDetails.loadingPromise;
}
// Start downloading of defer block dependencies.
tDetails.loadingPromise = Promise.allSettled(dependenciesFn()).then((results) => {
let failed = false;
const directiveDefs: DirectiveDefList = [];
const pipeDefs: PipeDefList = [];
for (const result of results) {
if (result.status === 'fulfilled') {
const dependency = result.value;
const directiveDef = getComponentDef(dependency) || getDirectiveDef(dependency);
if (directiveDef) {
directiveDefs.push(directiveDef);
} else {
const pipeDef = getPipeDef(dependency);
if (pipeDef) {
pipeDefs.push(pipeDef);
}
}
} else {
failed = true;
break;
}
}
// Loading is completed, we no longer need the loading Promise
// and a pending task should also be removed.
tDetails.loadingPromise = null;
pendingTasks.remove(taskId);
if (failed) {
tDetails.loadingState = DeferDependenciesLoadingState.FAILED;
if (tDetails.errorTmplIndex === null) {
const templateLocation = ngDevMode ? getTemplateLocationDetails(lView) : '';
const error = new RuntimeError(
RuntimeErrorCode.DEFER_LOADING_FAILED,
ngDevMode &&
'Loading dependencies for `@defer` block failed, ' +
`but no \`@error\` block was configured${templateLocation}. ` +
'Consider using the `@error` block to render an error state.',
);
handleError(lView, error);
}
} else {
tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE;
// Update directive and pipe registries to add newly downloaded dependencies.
const primaryBlockTView = primaryBlockTNode.tView!;
if (directiveDefs.length > 0) {
primaryBlockTView.directiveRegistry = addDepsToRegistry<DirectiveDefList>(
primaryBlockTView.directiveRegistry,
directiveDefs,
);
// Extract providers from all NgModules imported by standalone components
// used within this defer block.
const directiveTypes = directiveDefs.map((def) => def.type);
const providers = internalImportProvidersFrom(false, ...directiveTypes);
tDetails.providers = providers;
}
if (pipeDefs.length > 0) {
primaryBlockTView.pipeRegistry = addDepsToRegistry<PipeDefList>(
primaryBlockTView.pipeRegistry,
pipeDefs,
);
}
}
});
return tDetails.loadingPromise;
}
/**
* Attempts to trigger loading of defer block dependencies.
* If the block is already in a loading, completed or an error state -
* no additional actions are taken.
*/
export function triggerDeferBlock(lView: LView, tNode: TNode) {
const tView = lView[TVIEW];
const lContainer = lView[tNode.index];
const injector = lView[INJECTOR];
ngDevMode && assertLContainer(lContainer);
const lDetails = getLDeferBlockDetails(lView, tNode);
const tDetails = getTDeferBlockDetails(tView, tNode);
if (!shouldTriggerDeferBlock(injector, tDetails)) return;
// Defer block is triggered, cleanup all registered trigger functions.
invokeAllTriggerCleanupFns(lDetails);
switch (tDetails.loadingState) {
case DeferDependenciesLoadingState.NOT_STARTED:
renderDeferBlockState(DeferBlockState.Loading, tNode, lContainer);
triggerResourceLoading(tDetails, lView, tNode);
// The `loadingState` might have changed to "loading".
if (
(tDetails.loadingState as DeferDependenciesLoadingState) ===
DeferDependenciesLoadingState.IN_PROGRESS
) {
renderDeferStateAfterResourceLoading(tDetails, tNode, lContainer);
}
break;
case DeferDependenciesLoadingState.IN_PROGRESS:
renderDeferBlockState(DeferBlockState.Loading, tNode, lContainer);
renderDeferStateAfterResourceLoading(tDetails, tNode, lContainer);
break;
case DeferDependenciesLoadingState.COMPLETE:
ngDevMode && assertDeferredDependenciesLoaded(tDetails);
renderDeferBlockState(DeferBlockState.Complete, tNode, lContainer);
break;
case DeferDependenciesLoadingState.FAILED:
renderDeferBlockState(DeferBlockState.Error, tNode, lContainer);
break;
default:
if (ngDevMode) {
throwError('Unknown defer block state');
}
}
}
/**
* The core mechanism for incremental hydration. This triggers
* hydration for all the blocks in the tree that need to be hydrated
* and keeps track of all those blocks that were hydrated along the way.
*
* Note: the `replayQueuedEventsFn` is only provided when hydration is invoked
* as a result of an event replay (via JsAction). When hydration is invoked from
* an instruction set (e.g. `deferOnImmediate`) - there is no need to replay any
* events.
*/
export async function triggerHydrationFromBlockName(
injector: Injector,
blockName: string,
replayQueuedEventsFn?: Function,
) {
const dehydratedBlockRegistry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
const blocksBeingHydrated = dehydratedBlockRegistry.hydrating;
// Make sure we don't hydrate/trigger the same thing multiple times
if (blocksBeingHydrated.has(blockName)) {
return;
}
// The parent promise is the possible case of a list of defer blocks already being queued
// If it is queued, it'll exist; otherwise it'll be null. The hydration queue will contain all
// elements that need to be hydrated, sans any that have promises already
const {parentBlockPromise, hydrationQueue} = getParentBlockHydrationQueue(blockName, injector);
// The hydrating map in the registry prevents re-triggering hydration for a block that's already in
// the hydration queue. Here we generate promises for each of the blocks about to be hydrated
populateHydratingStateForQueue(dehydratedBlockRegistry, hydrationQueue);
// Trigger resource loading and hydration for the blocks in the queue in the order of highest block
// to lowest block. Once a block has finished resource loading, after next render fires after hydration
// finishes. The new block will have its defer instruction called and will be in the registry.
// Due to timing related to potential nested control flow, this has to be scheduled after the next render.
// Indicate that we have some pending async work.
const pendingTasks = injector.get(PendingTasksInternal);
const taskId = pendingTasks.add();
// If the parent block was being hydrated, but the process has
// not yet complete, wait until parent block promise settles before
// going over dehydrated blocks from the queue.
if (parentBlockPromise !== null) {
await parentBlockPromise;
}
// Actually do the triggering and hydration of the queue of blocks
for (const dehydratedBlockId of hydrationQueue) {
await triggerDeferBlockResourceLoading(dehydratedBlockId, dehydratedBlockRegistry);
await nextRender(injector);
// TODO(incremental-hydration): assert (in dev mode) that a defer block is present in the dehydrated registry
// at this point. If not - it means that the block has not been hydrated, for example due to different
// `@if` conditions on the client and the server. If we detect this case, we should also do the cleanup
// of all child block (promises, registry state, etc).
// TODO(incremental-hydration): call `rejectFn` when lDetails[DEFER_BLOCK_STATE] is `DeferBlockState.Error`.
blocksBeingHydrated.get(dehydratedBlockId)!.resolve();
// TODO(incremental-hydration): consider adding a wait for stability here
}
// Await hydration completion for the requested block.
await blocksBeingHydrated.get(blockName)?.promise;
// All async work is done, remove the taskId from the registry.
pendingTasks.remove(taskId);
// Replay any queued events, if any exist and the replay operation was requested.
if (replayQueuedEventsFn) {
replayQueuedEventsFn(hydrationQueue);
}
// Cleanup after hydration of all affected defer blocks.
cleanupHydratedDeferBlocks(
dehydratedBlockRegistry.get(blockName),
hydrationQueue,
dehydratedBlockRegistry,
injector.get(ApplicationRef),
);
}
/**
* Generates a new promise for every defer block in the hydrating queue
*/
function populateHydratingStateForQueue(registry: DehydratedBlockRegistry, queue: string[]) {
for (let blockId of queue) {
registry.hydrating.set(blockId, Promise.withResolvers());
}
}
// Waits for the next render cycle to complete
function nextRender(injector: Injector): Promise<void> {
let resolve: VoidFunction;
const promise = new Promise<void>((resolveFn) => {
resolve = resolveFn;
});
afterNextRender(() => resolve(), {injector});
return promise;
}
function triggerDeferBlockResourceLoading(
dehydratedBlockId: string,
dehydratedBlockRegistry: DehydratedBlockRegistry,
) {
let resolve: Function;
const promise = new Promise((resolveFn) => (resolve = resolveFn));
const deferBlock = dehydratedBlockRegistry.get(dehydratedBlockId);
// Since we trigger hydration for nested defer blocks in a sequence (parent -> child),
// there is a chance that a defer block may not be present at hydration time. For example,
// when a nested block was in an `@if` condition, which has changed.
if (deferBlock !== null) {
const {tNode, lView} = deferBlock;
const lDetails = getLDeferBlockDetails(lView, tNode);
onDeferBlockCompletion(lDetails, () => resolve());
triggerDeferBlock(lView, tNode);
// TODO(incremental-hydration): handle the cleanup for cases when
// defer block is no longer present during hydration (e.g. `@if` condition
// has changed during hydration/rendering).
}
return promise;
}
/**
* Registers cleanup functions for a defer block when the block has finished
* fetching and rendering
*/
function onDeferBlockCompletion(lDetails: LDeferBlockDetails, callback: VoidFunction) {
if (!Array.isArray(lDetails[ON_COMPLETE_FNS])) {
lDetails[ON_COMPLETE_FNS] = [];
}
lDetails[ON_COMPLETE_FNS].push(callback);
}
/**
* Determines whether "hydrate" triggers should be activated. Triggers are activated in the following cases:
* - on the server, when incremental hydration is enabled, to trigger the block and render the main content
* - on the client for blocks that were server-side rendered, to start hydration process
*/
export function shouldActivateHydrateTrigger(lView: LView, tNode: TNode): boolean {
const lDetails = getLDeferBlockDetails(lView, tNode);
const injector = lView[INJECTOR];
// TODO(incremental-hydration): ideally, this check should only happen once and then stored on
// LDeferBlockDetails as a flag. This would make subsequent lookups very cheap.
return (
isIncrementalHydrationEnabled(injector) &&
((typeof ngServerMode !== 'undefined' && ngServerMode) || lDetails[SSR_UNIQUE_ID] !== null)
);
}
// TODO(incremental-hydration): Optimize this further by moving the calculation to earlier
// in the process. Consider a flag we can check similar to LView[FLAGS].
/**
* Determines whether regular defer block triggers should be invoked based on client state
* and whether incremental hydration is enabled. Hydrate triggers are invoked elsewhere.
*/
export function shouldTriggerWhenOnClient(
injector: Injector,
lDetails: LDeferBlockDetails,
tDetails: TDeferBlockDetails,
): boolean {
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
return false;
}
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;
}
return true;
}
/**
* Returns whether defer blocks should be triggered.
*
* Currently, defer blocks are not triggered on the server,
* only placeholder content is rendered (if provided).
*/
export function shouldTriggerDeferBlock(
injector: Injector,
tDeferBlockDetails: TDeferBlockDetails,
): boolean {
const config = injector.get(DEFER_BLOCK_CONFIG, null, {optional: true});
if (config?.behavior === DeferBlockBehavior.Manual) {
return false;
}
return (
typeof ngServerMode === 'undefined' ||
!ngServerMode ||
tDeferBlockDetails.hydrateTriggers !== null
);
}
/**
* Retrives a Defer Block's list of hydration triggers
*/
export function getHydrateTriggers(
tView: TView,
tNode: TNode,
): Map<DeferBlockTrigger, number | null> {
const tDetails = getTDeferBlockDetails(tView, tNode);
return (tDetails.hydrateTriggers ??= new Map());
}
/**
* Retrives a Defer Block's list of prefetch triggers
*/
export function getPrefetchTriggers(tDetails: TDeferBlockDetails): Set<DeferBlockTrigger> {
return (tDetails.prefetchTriggers ??= new Set());
}
/**
* Loops through all defer block summaries and ensures all the blocks triggers are
* properly initialized
*/
export function processAndInitTriggers(
injector: Injector,
blockData: Map<string, BlockSummary>,
nodes: Map<string, Comment>,
) {
const idleElements: ElementTrigger[] = [];
const timerElements: ElementTrigger[] = [];
const viewportElements: ElementTrigger[] = [];
const immediateElements: ElementTrigger[] = [];
for (let [blockId, blockSummary] of blockData) {
const commentNode = nodes.get(blockId);
if (commentNode !== undefined) {
const numRootNodes = blockSummary.data[NUM_ROOT_NODES];
let currentNode: Comment | HTMLElement = commentNode;
for (let i = 0; i < numRootNodes; i++) {
currentNode = currentNode.previousSibling as HTMLElement;
if (currentNode.nodeType !== Node.ELEMENT_NODE) {
continue;
}
const elementTrigger: ElementTrigger = {el: currentNode, blockName: blockId};
// hydrate
if (blockSummary.hydrate.idle) {
idleElements.push(elementTrigger);
}
if (blockSummary.hydrate.immediate) {
immediateElements.push(elementTrigger);
}
if (blockSummary.hydrate.timer !== null) {
elementTrigger.delay = blockSummary.hydrate.timer;
timerElements.push(elementTrigger);
}
if (blockSummary.hydrate.viewport) {
viewportElements.push(elementTrigger);
}
}
}
}
setIdleTriggers(injector, idleElements);
setImmediateTriggers(injector, immediateElements);
setViewportTriggers(injector, viewportElements);
setTimerTriggers(injector, timerElements);
}
function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
for (const elementTrigger of elementTriggers) {
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
const onInvoke = () => triggerHydrationFromBlockName(injector, elementTrigger.blockName);
const cleanupFn = onIdle(onInvoke, injector);
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
}
}
function setViewportTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
if (elementTriggers.length > 0) {
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
for (let elementTrigger of elementTriggers) {
const cleanupFn = onViewport(
elementTrigger.el,
() => triggerHydrationFromBlockName(injector, elementTrigger.blockName),
injector,
);
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
}
}
}
function setTimerTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
for (const elementTrigger of elementTriggers) {
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
const onInvoke = () => triggerHydrationFromBlockName(injector, elementTrigger.blockName);
const timerFn = onTimer(elementTrigger.delay!);
const cleanupFn = timerFn(onInvoke, injector);
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
}
}
function setImmediateTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
for (const elementTrigger of elementTriggers) {
// Note: we intentionally avoid awaiting each call and instead kick off
// th hydration process simultaneously for all defer blocks with this trigger;
triggerHydrationFromBlockName(injector, elementTrigger.blockName);
}
}