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:
Jessica Janiuk 2024-11-18 14:30:38 -05:00
parent 5ce10264a4
commit d3491c7cee
8 changed files with 420 additions and 143 deletions

View file

@ -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({

View file

@ -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;
}
}
/**

View file

@ -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);
}
}

View file

@ -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));
}

View file

@ -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

View file

@ -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> {

View 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>;
}

View file

@ -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>');
});
});
});