diff --git a/packages/core/src/defer/registry.ts b/packages/core/src/defer/registry.ts index eb741dba0d08..7dca251dac35 100644 --- a/packages/core/src/defer/registry.ts +++ b/packages/core/src/defer/registry.ts @@ -85,7 +85,7 @@ export class DehydratedBlockRegistry { } // Blocks that are being hydrated. - hydrating = new Set(); + hydrating = new Map>(); /** @nocollapse */ static ɵprov = /** @pureOrBreakMyCode */ /* @__PURE__ */ ɵɵdefineInjectable({ diff --git a/packages/core/src/defer/rendering.ts b/packages/core/src/defer/rendering.ts index d9aa540bbb57..c95b8e933d5d 100644 --- a/packages/core/src/defer/rendering.ts +++ b/packages/core/src/defer/rendering.ts @@ -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; - } } /** diff --git a/packages/core/src/defer/triggering.ts b/packages/core/src/defer/triggering.ts index cb28a21fddcf..5478b117d548 100644 --- a/packages/core/src/defer/triggering.ts +++ b/packages/core/src/defer/triggering.ts @@ -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 { - 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 { - // 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 { let resolve: VoidFunction; const promise = new Promise((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; -}> { - 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()}; - - // 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(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); } } diff --git a/packages/core/src/hydration/cleanup.ts b/packages/core/src/hydration/cleanup.ts index 42f5f32d1a26..f55911b831a2 100644 --- a/packages/core/src/hydration/cleanup.ts +++ b/packages/core/src/hydration/cleanup.ts @@ -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, - injector: Injector, -): Promise { + 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)); } diff --git a/packages/core/src/hydration/event_replay.ts b/packages/core/src/hydration/event_replay.ts index b7bfec0f1255..174f3e728c68 100644 --- a/packages/core/src/hydration/event_replay.ts +++ b/packages/core/src/hydration/event_replay.ts @@ -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) { +function replayQueuedBlockEvents(hydratedBlocks: string[]) { // clone the queue const queue = [...blockEventQueue]; + const hydrated = new Set(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 diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index 1c963e093c18..14692c41c2f8 100644 --- a/packages/core/src/hydration/utils.ts +++ b/packages/core/src/hydration/utils.ts @@ -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 | 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 | 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 { diff --git a/packages/core/src/util/promise_with_resolvers.ts b/packages/core/src/util/promise_with_resolvers.ts new file mode 100644 index 000000000000..a0fe04dfa988 --- /dev/null +++ b/packages/core/src/util/promise_with_resolvers.ts @@ -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 { + promise: Promise; + resolve: (value: T | PromiseLike) => 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(); + * ``` + */ + withResolvers(): PromiseWithResolvers; +} diff --git a/packages/platform-server/test/incremental_hydration_spec.ts b/packages/platform-server/test/incremental_hydration_spec.ts index c21663437d4f..f9c31e1ea3ed 100644 --- a/packages/platform-server/test/incremental_hydration_spec.ts +++ b/packages/platform-server/test/incremental_hydration_spec.ts @@ -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(type: T, timeout = 0): Promise { + return new Promise((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(''); expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); - }, 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('

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

{ @Component({ @@ -607,7 +634,7 @@ describe('platform-server partial hydration integration', () => { appRef.tick(); expect(appHostNode.outerHTML).not.toContain('
{ @@ -682,7 +709,7 @@ describe('platform-server partial hydration integration', () => { expect(appHostNode.outerHTML).not.toContain( '
{ @Component({ @@ -755,7 +782,7 @@ describe('platform-server partial hydration integration', () => { expect(appHostNode.outerHTML).not.toContain( '
{ @@ -933,7 +960,7 @@ describe('platform-server partial hydration integration', () => { appRef.tick(); expect(appHostNode.outerHTML).toContain('end'); - }, 100_000); + }); }); it('immediate', async () => { @@ -985,6 +1012,7 @@ describe('platform-server partial hydration integration', () => { const compRef = getComponentRef(appRef); appRef.tick(); await whenStable(appRef); + appRef.tick(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('start'); @@ -1132,7 +1160,7 @@ describe('platform-server partial hydration integration', () => { appRef.tick(); expect(appHostNode.outerHTML).toContain('end'); - }, 100_000); + }); }); it('timer', async () => { @@ -1206,7 +1234,7 @@ describe('platform-server partial hydration integration', () => { appRef.tick(); expect(appHostNode.outerHTML).toContain('end'); - }, 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('end'); 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('

has a binding

'); expect(ssrContents).not.toContain('

shouldn\'t be annotated

'); - }, 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(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', () => { '
Outer block placeholder'); + 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(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('Client!'); expect(appHostNode.outerHTML).not.toContain('>Server!'); - }, 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: ` +
+ @defer (on viewport; hydrate on interaction) { +
+ Main defer block rendered! + @defer (on viewport; hydrate on interaction) { +

Nested defer block

+ } @placeholder { + Inner block placeholder + } +
+ } @placeholder { + Outer block placeholder + } +
+ `, + }) + 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(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: `

Deferred content

`, + }) + class DeferredCmp {} + + @Component({ + selector: 'other', + template: `

OtherCmp content

`, + }) + class OtherCmp {} + + @Component({ + selector: 'home', + imports: [RouterLink, DeferredCmp], + template: ` +
+ @defer (on viewport; hydrate on hover) { +
+ + @if (true) { + @defer (on viewport; hydrate on hover) { + +

Nested defer block

+ Go There + } @placeholder { + Inner block placeholder + } + } +
+ } @placeholder { + Outer block placeholder + } +
+ `, + }) + 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! + + `, + }) + 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(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('

OtherCmp content

'); }); }); });