mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
fix(core): Prevents race condition of cleanup for incremental hydration (#58722)
When hydrating a tree of blocks, this prevents cleanup from firing more than once per tree. It also ensures the cleanup happens after hydration has finished. fixes: #58690 PR Close #58722
This commit is contained in:
parent
5ce10264a4
commit
d3491c7cee
8 changed files with 420 additions and 143 deletions
|
|
@ -85,7 +85,7 @@ export class DehydratedBlockRegistry {
|
|||
}
|
||||
|
||||
// Blocks that are being hydrated.
|
||||
hydrating = new Set<string>();
|
||||
hydrating = new Map<string, PromiseWithResolvers<void>>();
|
||||
|
||||
/** @nocollapse */
|
||||
static ɵprov = /** @pureOrBreakMyCode */ /* @__PURE__ */ ɵɵdefineInjectable({
|
||||
|
|
|
|||
|
|
@ -320,13 +320,6 @@ function applyDeferBlockState(
|
|||
lDetails[ON_COMPLETE_FNS] = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (newState === DeferBlockState.Complete && Array.isArray(lDetails[ON_COMPLETE_FNS])) {
|
||||
for (const callback of lDetails[ON_COMPLETE_FNS]) {
|
||||
callback();
|
||||
}
|
||||
lDetails[ON_COMPLETE_FNS] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {afterNextRender} from '../render3/after_render/hooks';
|
|||
import {Injector} from '../di';
|
||||
import {internalImportProvidersFrom} from '../di/provider_collection';
|
||||
import {RuntimeError, RuntimeErrorCode} from '../errors';
|
||||
import {cleanupDeferBlock} from '../hydration/cleanup';
|
||||
import {cleanupHydratedDeferBlocks} from '../hydration/cleanup';
|
||||
import {BlockSummary, ElementTrigger, NUM_ROOT_NODES} from '../hydration/interfaces';
|
||||
import {
|
||||
assertSsrIdDefined,
|
||||
|
|
@ -39,7 +39,6 @@ import {
|
|||
DeferBlockState,
|
||||
DeferBlockTrigger,
|
||||
DeferDependenciesLoadingState,
|
||||
DehydratedDeferBlock,
|
||||
LDeferBlockDetails,
|
||||
ON_COMPLETE_FNS,
|
||||
SSR_BLOCK_STATE,
|
||||
|
|
@ -63,6 +62,7 @@ import {
|
|||
getPrimaryBlockTNode,
|
||||
getTDeferBlockDetails,
|
||||
} from './utils';
|
||||
import {ApplicationRef} from '../application/application_ref';
|
||||
|
||||
/**
|
||||
* Schedules triggering of a defer block for `on idle` and `on timer` conditions.
|
||||
|
|
@ -337,61 +337,126 @@ export function triggerDeferBlock(lView: LView, tNode: TNode) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Triggers hydration from a given defer block's unique SSR ID.
|
||||
* This includes firing any queued events that need to be replayed
|
||||
* and handling any post hydration cleanup.
|
||||
* 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,
|
||||
replayFn: Function = () => {},
|
||||
): Promise<void> {
|
||||
const {deferBlock, hydratedBlocks} = await triggerBlockTreeHydrationByName(injector, blockName);
|
||||
replayFn(hydratedBlocks);
|
||||
await cleanupDeferBlock(deferBlock, hydratedBlocks, injector);
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the resource loading for a defer block and passes back a promise
|
||||
* to handle cleanup on completion
|
||||
* Generates a new promise for every defer block in the hydrating queue
|
||||
*/
|
||||
export function triggerAndWaitForCompletion(
|
||||
dehydratedBlockId: string,
|
||||
dehydratedBlockRegistry: DehydratedBlockRegistry,
|
||||
injector: Injector,
|
||||
): Promise<void> {
|
||||
// TODO(incremental-hydration): This is a temporary fix to resolve control flow
|
||||
// cases where nested defer blocks are inside control flow. We wait for each nested
|
||||
// defer block to load and render before triggering the next one in a sequence. This is
|
||||
// needed to ensure that corresponding LViews & LContainers are available for a block
|
||||
// before we trigger it. We need to investigate how to get rid of the `afterNextRender`
|
||||
// calls (in the nearest future) and do loading of all dependencies of nested defer blocks
|
||||
// in parallel (later).
|
||||
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;
|
||||
}
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
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.
|
||||
// TODO(incremental-hydration): add tests to verify the behavior mentioned above.
|
||||
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).
|
||||
}
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -406,47 +471,6 @@ function onDeferBlockCompletion(lDetails: LDeferBlockDetails, callback: VoidFunc
|
|||
lDetails[ON_COMPLETE_FNS].push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function triggerBlockTreeHydrationByName(
|
||||
injector: Injector,
|
||||
blockName: string,
|
||||
): Promise<{
|
||||
deferBlock: DehydratedDeferBlock | null;
|
||||
hydratedBlocks: Set<string>;
|
||||
}> {
|
||||
const dehydratedBlockRegistry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
|
||||
|
||||
// Make sure we don't hydrate/trigger the same thing multiple times
|
||||
if (dehydratedBlockRegistry.hydrating.has(blockName))
|
||||
return {deferBlock: null, hydratedBlocks: new Set<string>()};
|
||||
|
||||
// Step 1: Get the queue of items that needs to be hydrated
|
||||
const hydrationQueue = getParentBlockHydrationQueue(blockName, injector);
|
||||
|
||||
// Step 2: Add all the items in the queue to the registry at once so we don't trigger hydration on them while
|
||||
// the sequence of triggers fires.
|
||||
hydrationQueue.forEach((id) => dehydratedBlockRegistry.hydrating.add(id));
|
||||
|
||||
// Step 3: hydrate each block in the queue. It will be in descending order from the top down.
|
||||
for (const dehydratedBlockId of hydrationQueue) {
|
||||
// Step 4: Run the actual trigger function to fetch dependencies.
|
||||
// Triggering a block adds any of its child defer blocks to the registry.
|
||||
await triggerAndWaitForCompletion(dehydratedBlockId, dehydratedBlockRegistry, injector);
|
||||
}
|
||||
|
||||
const hydratedBlocks = new Set<string>(hydrationQueue);
|
||||
|
||||
// The last item in the queue was the original target block;
|
||||
const hydratedBlockId = hydrationQueue.slice(-1)[0];
|
||||
const hydratedBlock = dehydratedBlockRegistry.get(hydratedBlockId)!;
|
||||
|
||||
return {deferBlock: hydratedBlock, hydratedBlocks};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -576,7 +600,7 @@ export function processAndInitTriggers(
|
|||
setTimerTriggers(injector, timerElements);
|
||||
}
|
||||
|
||||
async function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
for (const elementTrigger of elementTriggers) {
|
||||
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
|
||||
const onInvoke = () => triggerHydrationFromBlockName(injector, elementTrigger.blockName);
|
||||
|
|
@ -585,15 +609,13 @@ async function setIdleTriggers(injector: Injector, elementTriggers: ElementTrigg
|
|||
}
|
||||
}
|
||||
|
||||
async function setViewportTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
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,
|
||||
async () => {
|
||||
await triggerHydrationFromBlockName(injector, elementTrigger.blockName);
|
||||
},
|
||||
() => triggerHydrationFromBlockName(injector, elementTrigger.blockName),
|
||||
injector,
|
||||
);
|
||||
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
|
||||
|
|
@ -601,19 +623,20 @@ async function setViewportTriggers(injector: Injector, elementTriggers: ElementT
|
|||
}
|
||||
}
|
||||
|
||||
async function setTimerTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
function setTimerTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
for (const elementTrigger of elementTriggers) {
|
||||
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
|
||||
const onInvoke = async () =>
|
||||
await triggerHydrationFromBlockName(injector, elementTrigger.blockName);
|
||||
const onInvoke = () => triggerHydrationFromBlockName(injector, elementTrigger.blockName);
|
||||
const timerFn = onTimer(elementTrigger.delay!);
|
||||
const cleanupFn = timerFn(onInvoke, injector);
|
||||
registry.addCleanupFn(elementTrigger.blockName, cleanupFn);
|
||||
}
|
||||
}
|
||||
|
||||
async function setImmediateTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
function setImmediateTriggers(injector: Injector, elementTriggers: ElementTrigger[]) {
|
||||
for (const elementTrigger of elementTriggers) {
|
||||
await triggerHydrationFromBlockName(injector, elementTrigger.blockName);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@
|
|||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {ApplicationRef, whenStable} from '../application/application_ref';
|
||||
import {ApplicationRef} from '../application/application_ref';
|
||||
import {DehydratedDeferBlock} from '../defer/interfaces';
|
||||
import {DEHYDRATED_BLOCK_REGISTRY} from '../defer/registry';
|
||||
import {Injector} from '../di';
|
||||
import {DehydratedBlockRegistry} from '../defer/registry';
|
||||
import {
|
||||
CONTAINER_HEADER_OFFSET,
|
||||
DEHYDRATED_VIEWS,
|
||||
|
|
@ -138,20 +137,15 @@ export function cleanupDehydratedViews(appRef: ApplicationRef) {
|
|||
* hydrated. This removes all the jsaction attributes, timers, observers,
|
||||
* dehydrated views and containers
|
||||
*/
|
||||
export async function cleanupDeferBlock(
|
||||
export function cleanupHydratedDeferBlocks(
|
||||
deferBlock: DehydratedDeferBlock | null,
|
||||
hydratedBlocks: Set<string>,
|
||||
injector: Injector,
|
||||
): Promise<void> {
|
||||
hydratedBlocks: string[],
|
||||
registry: DehydratedBlockRegistry,
|
||||
appRef: ApplicationRef,
|
||||
): void {
|
||||
if (deferBlock !== null) {
|
||||
// hydratedBlocks is a set, and needs to be converted to an array
|
||||
// for removing listeners
|
||||
const registry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
|
||||
registry.cleanup([...hydratedBlocks]);
|
||||
registry.cleanup(hydratedBlocks);
|
||||
cleanupLContainer(deferBlock.lContainer);
|
||||
cleanupDehydratedViews(injector.get(ApplicationRef));
|
||||
cleanupDehydratedViews(appRef);
|
||||
}
|
||||
// we need to wait for app stability here so we don't continue before
|
||||
// the hydration process has finished, which could result in problems
|
||||
return whenStable(injector.get(ApplicationRef));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,24 +250,25 @@ export function invokeRegisteredReplayListeners(
|
|||
}
|
||||
}
|
||||
|
||||
export async function hydrateAndInvokeBlockListeners(
|
||||
function hydrateAndInvokeBlockListeners(
|
||||
blockName: string,
|
||||
injector: Injector,
|
||||
event: Event,
|
||||
currentTarget: Element,
|
||||
) {
|
||||
blockEventQueue.push({event, currentTarget});
|
||||
await triggerHydrationFromBlockName(injector, blockName, replayQueuedBlockEvents);
|
||||
triggerHydrationFromBlockName(injector, blockName, replayQueuedBlockEvents);
|
||||
}
|
||||
|
||||
function replayQueuedBlockEvents(hydratedBlocks: Set<string>) {
|
||||
function replayQueuedBlockEvents(hydratedBlocks: string[]) {
|
||||
// clone the queue
|
||||
const queue = [...blockEventQueue];
|
||||
const hydrated = new Set<string>(hydratedBlocks);
|
||||
// empty it
|
||||
blockEventQueue = [];
|
||||
for (let {event, currentTarget} of queue) {
|
||||
const blockName = currentTarget.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE)!;
|
||||
if (hydratedBlocks.has(blockName)) {
|
||||
if (hydrated.has(blockName)) {
|
||||
invokeListeners(event, currentTarget);
|
||||
} else {
|
||||
// requeue events that weren't yet hydrated
|
||||
|
|
|
|||
|
|
@ -554,31 +554,41 @@ export function convertHydrateTriggersToJsAction(
|
|||
* Builds a queue of blocks that need to be hydrated, looking up the
|
||||
* tree to the topmost defer block that exists in the tree that hasn't
|
||||
* been hydrated, but exists in the registry. This queue is in top down
|
||||
* heirarchical order as a list of defer block ids.
|
||||
* hierarchical order as a list of defer block ids.
|
||||
* Note: This is utilizing serialized information to navigate up the tree
|
||||
*/
|
||||
export function getParentBlockHydrationQueue(deferBlockId: string, injector: Injector) {
|
||||
export function getParentBlockHydrationQueue(
|
||||
deferBlockId: string,
|
||||
injector: Injector,
|
||||
): {parentBlockPromise: Promise<void> | null; hydrationQueue: string[]} {
|
||||
const dehydratedBlockRegistry = injector.get(DEHYDRATED_BLOCK_REGISTRY);
|
||||
const transferState = injector.get(TransferState);
|
||||
const deferBlockParents = transferState.get(NGH_DEFER_BLOCKS_KEY, {});
|
||||
|
||||
let isTopMostDeferBlock = false;
|
||||
let currentBlockId: string | null = deferBlockId;
|
||||
const deferBlockQueue: string[] = [];
|
||||
let parentBlockPromise: Promise<void> | null = null;
|
||||
const hydrationQueue: string[] = [];
|
||||
|
||||
while (!isTopMostDeferBlock && currentBlockId) {
|
||||
ngDevMode &&
|
||||
assertEqual(
|
||||
deferBlockQueue.indexOf(currentBlockId),
|
||||
hydrationQueue.indexOf(currentBlockId),
|
||||
-1,
|
||||
'Internal error: defer block hierarchy has a cycle.',
|
||||
);
|
||||
|
||||
deferBlockQueue.unshift(currentBlockId);
|
||||
isTopMostDeferBlock = dehydratedBlockRegistry.has(currentBlockId);
|
||||
const hydratingParentBlock = dehydratedBlockRegistry.hydrating.get(currentBlockId);
|
||||
if (parentBlockPromise === null && hydratingParentBlock != null) {
|
||||
// TODO: add an ngDevMode asset that `hydratingParentBlock.promise` exists and is of type Promise.
|
||||
parentBlockPromise = hydratingParentBlock.promise;
|
||||
break;
|
||||
}
|
||||
hydrationQueue.unshift(currentBlockId);
|
||||
currentBlockId = deferBlockParents[currentBlockId][DEFER_PARENT_BLOCK_ID];
|
||||
}
|
||||
return deferBlockQueue;
|
||||
return {parentBlockPromise, hydrationQueue};
|
||||
}
|
||||
|
||||
function gatherDeferBlocksByJSActionAttribute(doc: Document): Set<HTMLElement> {
|
||||
|
|
|
|||
29
packages/core/src/util/promise_with_resolvers.ts
Normal file
29
packages/core/src/util/promise_with_resolvers.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* TODO(incremental-hydration): Remove this file entirely once PromiseWithResolvers lands in stable
|
||||
* node / TS.
|
||||
*/
|
||||
interface PromiseWithResolvers<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
interface PromiseConstructor {
|
||||
/**
|
||||
* Creates a new Promise and returns it in an object, along with its resolve and reject functions.
|
||||
* @returns An object with the properties `promise`, `resolve`, and `reject`.
|
||||
*
|
||||
* ```ts
|
||||
* const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||
* ```
|
||||
*/
|
||||
withResolvers<T>(): PromiseWithResolvers<T>;
|
||||
}
|
||||
|
|
@ -13,14 +13,16 @@ import {
|
|||
inject,
|
||||
NgZone,
|
||||
PLATFORM_ID,
|
||||
Provider,
|
||||
signal,
|
||||
ɵwhenStable as whenStable,
|
||||
ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
|
||||
} from '@angular/core';
|
||||
|
||||
import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils';
|
||||
import {getComponentRef, ssr, timeout} from './hydration_utils';
|
||||
import {getDocument} from '@angular/core/src/render3/interfaces/document';
|
||||
import {isPlatformServer} from '@angular/common';
|
||||
import {isPlatformServer, Location, PlatformLocation} from '@angular/common';
|
||||
import {
|
||||
provideClientHydration,
|
||||
withEventReplay,
|
||||
|
|
@ -31,6 +33,31 @@ import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
|
|||
import {DEHYDRATED_BLOCK_REGISTRY} from '@angular/core/src/defer/registry';
|
||||
import {JSACTION_BLOCK_ELEMENT_MAP} from '@angular/core/src/hydration/tokens';
|
||||
import {JSACTION_EVENT_CONTRACT} from '@angular/core/src/event_delegation_utils';
|
||||
import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router';
|
||||
import {MockPlatformLocation} from '@angular/common/testing';
|
||||
|
||||
/**
|
||||
* Emulates a dynamic import promise.
|
||||
*
|
||||
* Note: `setTimeout` is used to make `fixture.whenStable()` function
|
||||
* wait for promise resolution, since `whenStable()` relies on the state
|
||||
* of a macrotask queue.
|
||||
*/
|
||||
function dynamicImportOf<T>(type: T, timeout = 0): Promise<T> {
|
||||
return new Promise<T>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(type);
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to await all pending dynamic imports
|
||||
* emulated using `dynamicImportOf` function.
|
||||
*/
|
||||
function allPendingDynamicImports() {
|
||||
return dynamicImportOf(null, 101);
|
||||
}
|
||||
|
||||
describe('platform-server partial hydration integration', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
|
@ -117,7 +144,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(ssrContents).toContain('<button jsaction="click:;" ngb="d1">Click B</button>');
|
||||
expect(ssrContents).toContain('<!--ngh=d0-->');
|
||||
expect(ssrContents).toContain('<!--ngh=d1-->');
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('basic hydration behavior', () => {
|
||||
|
|
@ -237,7 +264,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
|
||||
// Inner defer block was not triggered, thus it retains `jsaction` attributes.
|
||||
expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1');
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('should SSR and hydrate nested `@defer` blocks', async () => {
|
||||
@Component({
|
||||
|
|
@ -348,7 +375,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
// Since inner `@defer` block was triggered, all parent blocks
|
||||
// were hydrated as well, so all `jsaction` attributes are removed.
|
||||
expect(appHostNode.outerHTML).not.toContain('jsaction="');
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('should SSR and hydrate only defer blocks with hydrate syntax', async () => {
|
||||
@Component({
|
||||
|
|
@ -462,7 +489,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
// Since inner `@defer` block was triggered, all parent blocks
|
||||
// were hydrated as well, so all `jsaction` attributes are removed.
|
||||
expect(appHostNode.outerHTML).not.toContain('jsaction="');
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
/* TODO: tests to add
|
||||
|
|
@ -538,7 +565,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
|
||||
appRef.tick();
|
||||
expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;keydown:;"');
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('keydown', async () => {
|
||||
@Component({
|
||||
|
|
@ -607,7 +634,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
appRef.tick();
|
||||
|
||||
expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;keydown:;"');
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydrate on hover', () => {
|
||||
|
|
@ -682,7 +709,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(appHostNode.outerHTML).not.toContain(
|
||||
'<div jsaction="mouseenter:;mouseover:;focusin:;"',
|
||||
);
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('focusin', async () => {
|
||||
@Component({
|
||||
|
|
@ -755,7 +782,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(appHostNode.outerHTML).not.toContain(
|
||||
'<div jsaction="mouseenter:;mouseover:;focusin:;"',
|
||||
);
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewport', () => {
|
||||
|
|
@ -933,7 +960,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
appRef.tick();
|
||||
|
||||
expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('immediate', async () => {
|
||||
|
|
@ -985,6 +1012,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
const compRef = getComponentRef<SimpleComponent>(appRef);
|
||||
appRef.tick();
|
||||
await whenStable(appRef);
|
||||
appRef.tick();
|
||||
|
||||
const appHostNode = compRef.location.nativeElement;
|
||||
expect(appHostNode.outerHTML).toContain('<span id="test">start</span>');
|
||||
|
|
@ -1132,7 +1160,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
appRef.tick();
|
||||
|
||||
expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
it('timer', async () => {
|
||||
|
|
@ -1206,7 +1234,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
appRef.tick();
|
||||
|
||||
expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('never', async () => {
|
||||
@Component({
|
||||
|
|
@ -1270,7 +1298,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
appRef.tick();
|
||||
|
||||
expect(appHostNode.outerHTML).not.toContain('Outer block placeholder');
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('defer triggers should not fire when hydrate never is used', async () => {
|
||||
@Component({
|
||||
|
|
@ -1346,7 +1374,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(appHostNode.outerHTML).not.toContain('<span id="test">end</span>');
|
||||
|
||||
expect(appHostNode.outerHTML).not.toContain('Outer block placeholder');
|
||||
}, 100_000);
|
||||
});
|
||||
|
||||
it('should not annotate jsaction events for events inside a hydrate never block', async () => {
|
||||
@Component({
|
||||
|
|
@ -1398,7 +1426,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(ssrContents).toContain('<p jsaction="click:;" ngb="d1">has a binding</p>');
|
||||
expect(ssrContents).not.toContain('<p id="test2" jsaction="click:;');
|
||||
expect(ssrContents).toContain('<p id="test2">shouldn\'t be annotated</p>');
|
||||
}, 100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client side navigation', () => {
|
||||
|
|
@ -1476,6 +1504,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
fnB() {
|
||||
this.value.set('end');
|
||||
}
|
||||
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
|
||||
}
|
||||
|
||||
const appId = 'custom-app-id';
|
||||
|
|
@ -1501,6 +1530,8 @@ describe('platform-server partial hydration integration', () => {
|
|||
hydrationFeatures,
|
||||
});
|
||||
const compRef = getComponentRef<SimpleComponent>(appRef);
|
||||
const registry = compRef.instance.registry;
|
||||
spyOn(registry, 'cleanup').and.callThrough();
|
||||
appRef.tick();
|
||||
await whenStable(appRef);
|
||||
|
||||
|
|
@ -1521,6 +1552,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
'<article id="item-1" jsaction="click:;keydown:;"',
|
||||
);
|
||||
expect(appHostNode.outerHTML).not.toContain('<span>Outer block placeholder</span>');
|
||||
expect(registry.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1549,6 +1581,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
class SimpleComponent {
|
||||
fnA() {}
|
||||
isServer = isPlatformServer(inject(PLATFORM_ID));
|
||||
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
|
||||
}
|
||||
|
||||
const appId = 'custom-app-id';
|
||||
|
|
@ -1577,6 +1610,9 @@ describe('platform-server partial hydration integration', () => {
|
|||
hydrationFeatures,
|
||||
});
|
||||
const compRef = getComponentRef<SimpleComponent>(appRef);
|
||||
const registry = compRef.instance.registry;
|
||||
spyOn(registry, 'cleanup').and.callThrough();
|
||||
|
||||
appRef.tick();
|
||||
await whenStable(appRef);
|
||||
const appHostNode = compRef.location.nativeElement;
|
||||
|
|
@ -1598,7 +1634,8 @@ describe('platform-server partial hydration integration', () => {
|
|||
appRef.tick();
|
||||
expect(appHostNode.outerHTML).toContain('<span>Client!</span>');
|
||||
expect(appHostNode.outerHTML).not.toContain('>Server!</span>');
|
||||
}, 100_000);
|
||||
expect(registry.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should clear registry of blocks as they are hydrated', async () => {
|
||||
@Component({
|
||||
|
|
@ -1653,6 +1690,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
const jsActionMap = compRef.instance.jsActionMap;
|
||||
const contract = compRef.instance.contract;
|
||||
spyOn(contract.instance!, 'cleanUp').and.callThrough();
|
||||
spyOn(registry, 'cleanup').and.callThrough();
|
||||
|
||||
expect(registry.size).toBe(1);
|
||||
expect(jsActionMap.size).toBe(2);
|
||||
|
|
@ -1667,6 +1705,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(registry.size).toBe(1);
|
||||
expect(registry.has('d0')).toBeFalsy();
|
||||
expect(jsActionMap.size).toBe(1);
|
||||
expect(registry.cleanup).toHaveBeenCalledTimes(1);
|
||||
|
||||
const nested = doc.getElementById('nested')!;
|
||||
const clickEvent2 = new CustomEvent('click', {bubbles: true});
|
||||
|
|
@ -1677,6 +1716,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(registry.size).toBe(0);
|
||||
expect(jsActionMap.size).toBe(0);
|
||||
expect(contract.instance!.cleanUp).toHaveBeenCalled();
|
||||
expect(registry.cleanup).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should clear registry of multiple blocks if they are hydrated in one go', async () => {
|
||||
|
|
@ -1748,6 +1788,77 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(contract.instance!.cleanUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clean up only one time per stack of blocks post hydration', async () => {
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app',
|
||||
template: `
|
||||
<main (click)="fnA()">
|
||||
@defer (on viewport; hydrate on interaction) {
|
||||
<div id="main" (click)="fnA()">
|
||||
Main defer block rendered!
|
||||
@defer (on viewport; hydrate on interaction) {
|
||||
<p id="nested">Nested defer block</p>
|
||||
} @placeholder {
|
||||
<span>Inner block placeholder</span>
|
||||
}
|
||||
</div>
|
||||
} @placeholder {
|
||||
<span>Outer block placeholder</span>
|
||||
}
|
||||
</main>
|
||||
`,
|
||||
})
|
||||
class SimpleComponent {
|
||||
fnA() {}
|
||||
|
||||
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
|
||||
jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
|
||||
contract = inject(JSACTION_EVENT_CONTRACT);
|
||||
}
|
||||
|
||||
const appId = 'custom-app-id';
|
||||
const providers = [{provide: APP_ID, useValue: appId}];
|
||||
const hydrationFeatures = () => [withIncrementalHydration()];
|
||||
|
||||
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
|
||||
|
||||
// Internal cleanup before we do server->client transition in this test.
|
||||
resetTViewsFor(SimpleComponent);
|
||||
|
||||
////////////////////////////////
|
||||
const doc = getDocument();
|
||||
|
||||
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
|
||||
envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
|
||||
hydrationFeatures,
|
||||
});
|
||||
const compRef = getComponentRef<SimpleComponent>(appRef);
|
||||
appRef.tick();
|
||||
await whenStable(appRef);
|
||||
|
||||
const registry = compRef.instance.registry;
|
||||
const jsActionMap = compRef.instance.jsActionMap;
|
||||
const contract = compRef.instance.contract;
|
||||
spyOn(contract.instance!, 'cleanUp').and.callThrough();
|
||||
spyOn(registry, 'cleanup').and.callThrough();
|
||||
|
||||
expect(registry.size).toBe(1);
|
||||
expect(jsActionMap.size).toBe(2);
|
||||
expect(registry.has('d0')).toBeTruthy();
|
||||
|
||||
const nested = doc.getElementById('nested')!;
|
||||
const clickEvent2 = new CustomEvent('click', {bubbles: true});
|
||||
nested.dispatchEvent(clickEvent2);
|
||||
await timeout(1000); // wait for defer blocks to resolve
|
||||
appRef.tick();
|
||||
|
||||
expect(registry.size).toBe(0);
|
||||
expect(jsActionMap.size).toBe(0);
|
||||
expect(contract.instance!.cleanUp).toHaveBeenCalled();
|
||||
expect(registry.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should leave blocks in registry when not hydrated', async () => {
|
||||
@Component({
|
||||
standalone: true,
|
||||
|
|
@ -1806,6 +1917,7 @@ describe('platform-server partial hydration integration', () => {
|
|||
|
||||
const registry = compRef.instance.registry;
|
||||
const jsActionMap = compRef.instance.jsActionMap;
|
||||
spyOn(registry, 'cleanup').and.callThrough();
|
||||
|
||||
// registry size should be the number of highest level dehydrated defer blocks
|
||||
// in this case, 2.
|
||||
|
|
@ -1825,6 +1937,121 @@ describe('platform-server partial hydration integration', () => {
|
|||
expect(jsActionMap.size).toBe(1);
|
||||
expect(registry.has('d2')).toBeTruthy();
|
||||
expect(contract.instance!.cleanUp).not.toHaveBeenCalled();
|
||||
expect(registry.cleanup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Router', () => {
|
||||
it('should trigger event replay after next render', async () => {
|
||||
@Component({
|
||||
selector: 'deferred',
|
||||
template: `<p>Deferred content</p>`,
|
||||
})
|
||||
class DeferredCmp {}
|
||||
|
||||
@Component({
|
||||
selector: 'other',
|
||||
template: `<p>OtherCmp content</p>`,
|
||||
})
|
||||
class OtherCmp {}
|
||||
|
||||
@Component({
|
||||
selector: 'home',
|
||||
imports: [RouterLink, DeferredCmp],
|
||||
template: `
|
||||
<main (click)="fnA()">
|
||||
@defer (on viewport; hydrate on hover) {
|
||||
<div id="main" (click)="fnA()">
|
||||
<aside>Main defer block rendered!</aside>
|
||||
@if (true) {
|
||||
@defer (on viewport; hydrate on hover) {
|
||||
<deferred />
|
||||
<p id="nested">Nested defer block</p>
|
||||
<a id="route-link" [routerLink]="[path, thing(), stuff()]">Go There</a>
|
||||
} @placeholder {
|
||||
<span>Inner block placeholder</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @placeholder {
|
||||
<span>Outer block placeholder</span>
|
||||
}
|
||||
</main>
|
||||
`,
|
||||
})
|
||||
class HomeCmp {
|
||||
path = 'other';
|
||||
thing = signal('thing');
|
||||
stuff = signal('stuff');
|
||||
fnA() {}
|
||||
}
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomeCmp,
|
||||
},
|
||||
{
|
||||
path: 'other/thing/stuff',
|
||||
component: OtherCmp,
|
||||
},
|
||||
];
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app',
|
||||
imports: [RouterOutlet],
|
||||
template: `
|
||||
Works!
|
||||
<router-outlet />
|
||||
`,
|
||||
})
|
||||
class SimpleComponent {
|
||||
location = inject(Location);
|
||||
}
|
||||
|
||||
const deferDepsInterceptor = {
|
||||
intercept() {
|
||||
return () => {
|
||||
return [dynamicImportOf(DeferredCmp, 100)];
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const appId = 'custom-app-id';
|
||||
const providers = [
|
||||
{provide: APP_ID, useValue: appId},
|
||||
{provide: PlatformLocation, useClass: MockPlatformLocation},
|
||||
{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
|
||||
provideRouter(routes),
|
||||
] as unknown as Provider[];
|
||||
const hydrationFeatures = () => [withIncrementalHydration()];
|
||||
|
||||
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
|
||||
|
||||
resetTViewsFor(SimpleComponent, HomeCmp, DeferredCmp);
|
||||
|
||||
const doc = getDocument();
|
||||
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
|
||||
envProviders: [...providers],
|
||||
hydrationFeatures,
|
||||
});
|
||||
const compRef = getComponentRef<SimpleComponent>(appRef);
|
||||
await appRef.whenStable();
|
||||
const appHostNode = compRef.location.nativeElement;
|
||||
const location = compRef.instance.location;
|
||||
|
||||
const routeLink = doc.getElementById('route-link')!;
|
||||
routeLink.click();
|
||||
await timeout(1000); // wait for defer blocks to resolve
|
||||
appRef.tick();
|
||||
|
||||
await allPendingDynamicImports();
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(location.path()).toBe('/other/thing/stuff');
|
||||
|
||||
expect(appHostNode.outerHTML).toContain('<p>OtherCmp content</p>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue