/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { APP_ID, ApplicationRef, Component, ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY, destroyPlatform, ɵgetDocument as getDocument, inject, Input, ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP, ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT, PendingTasks, PLATFORM_ID, Provider, QueryList, ɵresetIncrementalHydrationEnabledWarnedForTests as resetIncrementalHydrationEnabledWarnedForTests, signal, ɵTimerScheduler as TimerScheduler, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, } from '@angular/core'; import { DOCUMENT, isPlatformServer, Location, ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID, PlatformLocation, } from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; import {TestBed} from '@angular/core/testing'; import { provideClientHydration, withEventReplay, withIncrementalHydration, } from '@angular/platform-browser'; import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router'; import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils'; import { clearConsole, getComponentRef, resetNgDevModeCounters, ssr, timeout, verifyHasLog, verifyNodeWasHydrated, verifyNodeWasNotHydrated, withDebugConsole, } from './hydration_utils'; /** * 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); }); } /** * Emulates a failed dynamic import promise. */ function failedDynamicImport(): Promise { return new Promise((_, reject) => { setTimeout(() => reject()); }); } /** * 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; beforeAll(async () => { globalThis.window = globalThis as unknown as Window & typeof globalThis; await import('../../core/primitives/event-dispatch/contract_bundle_min.js' as string); }); afterAll(() => { globalThis.window = originalWindow; }); afterEach(() => { destroyPlatform(); window._ejsas = {}; }); describe('core functionality', () => { beforeEach(() => { clearConsole(TestBed.inject(ApplicationRef)); resetNgDevModeCounters(); }); afterEach(() => { clearConsole(TestBed.inject(ApplicationRef)); }); describe('annotation', () => { it('should annotate inner components with defer block id', async () => { @Component({ selector: 'dep-a', template: '', }) class DepA {} @Component({ selector: 'dep-b', imports: [DepA], template: ` `, }) class DepB {} @Component({ selector: 'app', imports: [DepB], template: `
@defer (on viewport; hydrate on interaction) {
Main defer block rendered! @if (visible) { Defer events work! }
@defer (on viewport; hydrate on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { items = [1, 2, 3]; visible = false; fnA() {} showMessage() { this.visible = true; } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('
'); // Buttons inside nested components inherit parent defer block namespace. expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); }); it('should not include trigger array when only JSAction triggers are present', async () => { @Component({ selector: 'dep-a', template: '', }) class DepA {} @Component({ selector: 'dep-b', imports: [DepA], template: ` `, }) class DepB {} @Component({ selector: 'app', imports: [DepB], template: `
@defer (on viewport; hydrate on interaction) {
Main defer block rendered! @if (visible) { Defer events work! }
@defer (on viewport; hydrate on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { items = [1, 2, 3]; visible = false; fnA() {} showMessage() { this.visible = true; } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":2,"s":2,"p":"d0"}}', ); }); it('should include trigger array for non-jsaction triggers', async () => { @Component({ selector: 'dep-a', template: '', }) class DepA {} @Component({ selector: 'dep-b', imports: [DepA], template: ` `, }) class DepB {} @Component({ selector: 'app', imports: [DepB], template: `
@defer (on viewport; hydrate on interaction) {
Main defer block rendered! @if (visible) { Defer events work! }
@defer (on viewport; hydrate on viewport) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { items = [1, 2, 3]; visible = false; fnA() {} showMessage() { this.visible = true; } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":2,"s":2,"t":[2],"p":"d0"}}', ); }); it('should not include parent id in serialized data for top-level `@defer` blocks', async () => { @Component({ selector: 'app', template: ` @defer (on viewport; hydrate on interaction) { Hello world! } @placeholder { Placeholder } `, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, { envProviders: providers, hydrationFeatures, }); const ssrContents = getAppContents(html); // Assert that the serialized data doesn't contain the "p" field, // which contains parent id (which is not needed for top-level blocks). expect(ssrContents).toContain('"__nghDeferData__":{"d0":{"r":1,"s":2}}}'); }); }); describe('basic hydration behavior', () => { it('should SSR and hydrate top-level `@defer` blocks', async () => { @Component({ selector: 'app', template: `
@defer (on viewport; hydrate on interaction) {
Main defer block rendered! @if (visible) { Defer events work! } @defer (on viewport; hydrate on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { items = [1, 2, 3]; visible = false; fnA() {} showMessage() { this.visible = true; } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, { envProviders: providers, hydrationFeatures, }); const ssrContents = getAppContents(html); // Assert that we have `jsaction` annotations and // defer blocks are triggered and rendered. //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; // At this point an eager part of an app is hydrated, // but defer blocks are still in dehydrated state. //
no longer has `jsaction` attribute. expect(appHostNode.outerHTML).toContain('
'); // Elements from @defer blocks still have `jsaction` annotations, // since they were not triggered yet. expect(appHostNode.outerHTML).toContain('
@defer (on viewport; hydrate on interaction) {
Main defer block rendered! @if (visible) { Defer events work! }
@defer (on viewport; hydrate on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { items = [1, 2, 3]; visible = false; fnA() {} showMessage() { this.visible = true; } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); // Assert that we have `jsaction` annotations and // defer blocks are triggered and rendered. //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
is inside a nested defer block -> different namespace. expect(ssrContents).toContain('

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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; // At this point an eager part of an app is hydrated, // but defer blocks are still in dehydrated state. //

no longer has `jsaction` attribute. expect(appHostNode.outerHTML).toContain('
'); // Elements from @defer blocks still have `jsaction` annotations, // since they were not triggered yet. expect(appHostNode.outerHTML).toContain('
@defer (hydrate on interaction) {
Main defer block rendered! @if (visible) { Defer events work! }
@defer (on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { items = [1, 2, 3]; visible = false; fnA() {} showMessage() { this.visible = true; } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}, withDebugConsole()]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); // Assert that we have `jsaction` annotations and // defer blocks are triggered and rendered. //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
is inside a nested defer block -> different namespace. // expect(ssrContents).toContain('

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 appRef.whenStable(); verifyHasLog( appRef, 'Angular hydrated 1 component(s) and 8 node(s), 0 component(s) were skipped. 1 defer block(s) were configured to use incremental hydration.', ); const appHostNode = compRef.location.nativeElement; // At this point an eager part of an app is hydrated, // but defer blocks are still in dehydrated state. //

no longer has `jsaction` attribute. expect(appHostNode.outerHTML).toContain('
'); // Elements from @defer blocks still have `jsaction` annotations, // since they were not triggered yet. expect(appHostNode.outerHTML).toContain('
{ @Component({ selector: 'app', template: ` @defer (on viewport; hydrate on interaction) {
Level 1 @defer (on viewport; hydrate on interaction) {
Level 2 @defer (on viewport; hydrate on interaction) {
Level 3 @defer (on viewport; hydrate on interaction) {
Level 4
} @placeholder { Level 4 placeholder }
} @placeholder { Level 3 placeholder }
} @placeholder { Level 2 placeholder }
} @placeholder { Level 1 placeholder } `, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); // Check that all levels are rendered expect(ssrContents).toContain('Level 1'); expect(ssrContents).toContain('Level 2'); expect(ssrContents).toContain('Level 3'); expect(ssrContents).toContain('Level 4'); // Check the transfer state data expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"},"d2":{"r":1,"s":2,"p":"d1"},"d3":{"r":1,"s":2,"p":"d2"}}', ); }); it('should have correct transfer state data for nested defer blocks with different triggers', async () => { @Component({ selector: 'app', template: ` @defer (on viewport; hydrate on interaction) {
Level 1 @defer (on viewport; hydrate on viewport) {
Level 2
} @placeholder { Level 2 placeholder }
} @placeholder { Level 1 placeholder } `, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); // Check that levels are rendered expect(ssrContents).toContain('Level 1'); expect(ssrContents).toContain('Level 2'); // Check the transfer state data with trigger array expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"t":[2],"p":"d0"}}', ); }); }); describe('triggers', () => { describe('hydrate on interaction', () => { it('click', async () => { @Component({ selector: 'app', template: `
@defer (on viewport; hydrate on interaction) {
defer block rendered!
{{ value() }} } @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('
{ @Component({ selector: 'app', template: `
@defer (on viewport; hydrate on interaction) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('
{ it('mouseover', async () => { @Component({ selector: 'app', template: `
@defer (hydrate on hover) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '
{ @Component({ selector: 'app', template: `
@defer (hydrate on hover) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '
{ let activeObservers: MockIntersectionObserver[] = []; let nativeIntersectionObserver: typeof IntersectionObserver; beforeEach(() => { nativeIntersectionObserver = globalThis.IntersectionObserver; globalThis.IntersectionObserver = MockIntersectionObserver; }); afterEach(() => { globalThis.IntersectionObserver = nativeIntersectionObserver; activeObservers = []; }); /** * Mocked out implementation of the native IntersectionObserver API. We need to * mock it out for tests, because it's unsupported in Domino and we can't trigger * it reliably in the browser. */ class MockIntersectionObserver implements IntersectionObserver { root = null; rootMargin = null!; thresholds = null!; scrollMargin = ''; observedElements = new Set(); private elementsInView = new Set(); constructor( private callback: IntersectionObserverCallback, readonly options: IntersectionObserverInit | null = null, ) { activeObservers.push(this); } static invokeCallbacksForElement(element: Element, isInView: boolean) { for (const observer of activeObservers) { const elements = observer.elementsInView; const wasInView = elements.has(element); if (isInView) { elements.add(element); } else { elements.delete(element); } observer.invokeCallback(); if (wasInView) { elements.add(element); } else { elements.delete(element); } } } private invokeCallback() { for (const el of this.observedElements) { this.callback( [ { target: el, isIntersecting: this.elementsInView.has(el), // Unsupported properties. boundingClientRect: null!, intersectionRatio: null!, intersectionRect: null!, rootBounds: null, time: null!, }, ], this, ); } } observe(element: Element) { this.observedElements.add(element); // Native observers fire their callback as soon as an // element is observed so we try to mimic it here. this.invokeCallback(); } unobserve(element: Element) { this.observedElements.delete(element); } disconnect() { this.observedElements.clear(); this.elementsInView.clear(); } takeRecords(): IntersectionObserverEntry[] { throw new Error('Not supported'); } } it('viewport', async () => { @Component({ selector: 'app', template: `
@defer (hydrate on viewport) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
start', ); const article: HTMLElement = document.getElementsByTagName('article')[0]; MockIntersectionObserver.invokeCallbacksForElement(article, false); appRef.tick(); const testElement = doc.getElementById('test')!; const clickEvent = new CustomEvent('click'); testElement.dispatchEvent(clickEvent); appRef.tick(); expect(appHostNode.outerHTML).toContain( 'start', ); MockIntersectionObserver.invokeCallbacksForElement(article, true); await allPendingDynamicImports(); const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('end'); }); it('should create IntersectionObserver with the options from the `hydrate on viewport` trigger', async () => { @Component({ selector: 'app', template: `
@defer (hydrate on viewport({rootMargin: '123px', threshold: 0.5})) {
defer block rendered!
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2,"t":[{"trigger":2,"intersectionObserverOptions":{"rootMargin":"123px","threshold":0.5}}]}}', ); // 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, }); appRef.tick(); await appRef.whenStable(); expect(activeObservers.length).toBe(1); expect(activeObservers[0].options).toEqual({rootMargin: '123px', threshold: 0.5}); }); }); it('immediate', async () => { @Component({ selector: 'app', template: `
@defer (hydrate on immediate) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
start'); const testElement = doc.getElementById('test')!; const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('end'); }); describe('idle', () => { /** * Sets up interceptors for when an idle callback is requested * and when it's cancelled. This is needed to keep track of calls * made to `requestIdleCallback` and `cancelIdleCallback` APIs. */ let id = 0; let idleCallbacksRequested: number; let idleCallbacksInvoked: number; let idleCallbacksCancelled: number; const onIdleCallbackQueue: Map = new Map(); function resetCounters() { idleCallbacksRequested = 0; idleCallbacksInvoked = 0; idleCallbacksCancelled = 0; } resetCounters(); let nativeRequestIdleCallback: ( callback: IdleRequestCallback, options?: IdleRequestOptions, ) => number; let nativeCancelIdleCallback: (id: number) => void; const mockRequestIdleCallback = ( callback: IdleRequestCallback, options?: IdleRequestOptions, ): number => { onIdleCallbackQueue.set(id, callback); expect(idleCallbacksRequested).toBe(0); idleCallbacksRequested++; return id++; }; const mockCancelIdleCallback = (id: number) => { onIdleCallbackQueue.delete(id); idleCallbacksRequested--; idleCallbacksCancelled++; }; const triggerIdleCallbacks = () => { for (const [_, callback] of onIdleCallbackQueue) { idleCallbacksInvoked++; callback(null!); } onIdleCallbackQueue.clear(); }; beforeEach(() => { nativeRequestIdleCallback = globalThis.requestIdleCallback; nativeCancelIdleCallback = globalThis.cancelIdleCallback; globalThis.requestIdleCallback = mockRequestIdleCallback; globalThis.cancelIdleCallback = mockCancelIdleCallback; resetCounters(); }); afterEach(() => { globalThis.requestIdleCallback = nativeRequestIdleCallback; globalThis.cancelIdleCallback = nativeCancelIdleCallback; onIdleCallbackQueue.clear(); resetCounters(); }); it('idle', async () => { @Component({ selector: 'app', template: `
@defer (hydrate on idle) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
start'); const testElement = doc.getElementById('test')!; const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('end'); }); }); describe('timer', () => { class FakeTimerScheduler { add(delay: number, callback: VoidFunction) { callback(); } remove(callback: VoidFunction) { /* noop */ } } it('top level timer', async () => { @Component({ selector: 'app', template: `
@defer (hydrate on timer(150)) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [ {provide: APP_ID, useValue: appId}, {provide: TimerScheduler, useClass: FakeTimerScheduler}, ]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
start'); }); it('nested timer', async () => { @Component({ selector: 'app', template: `
@defer (on viewport; hydrate on interaction) {
defer block rendered! @defer (on viewport; hydrate on timer(150)) {

Nested defer block

{{ value() }}
} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} constructor() { if (!isPlatformServer(inject(PLATFORM_ID))) { this.value.set('end'); } } } const appId = 'custom-app-id'; const providers = [ {provide: APP_ID, useValue: appId}, {provide: TimerScheduler, useClass: FakeTimerScheduler}, ]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
end'); }); }); it('when', async () => { @Component({ selector: 'app', template: `
@defer (on immediate; hydrate when iSaySo()) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); iSaySo = signal(false); fnA() {} triggerHydration() { this.iSaySo.set(true); } fnB() { this.value.set('end'); } registry = inject(DEHYDRATED_BLOCK_REGISTRY); } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
start', ); expect(registry.has('d0')).toBeTruthy(); const testElement = doc.getElementById('hydrate-me')!; const clickEvent = new CustomEvent('click'); testElement.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); await appRef.whenStable(); verifyNodeWasHydrated(article); expect(registry.cleanup).toHaveBeenCalledTimes(1); expect(registry.has('d0')).toBeFalsy(); expect(appHostNode.outerHTML).toContain('start'); }); it('never', async () => { @Component({ selector: 'app', template: `
@defer (hydrate never) {
defer block rendered!
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
@defer (on timer(1s); hydrate never) {
defer block rendered! {{ value() }}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
end'); expect(appHostNode.outerHTML).not.toContain('Outer block placeholder'); }); it('should not annotate jsaction events for events inside a hydrate never block', async () => { @Component({ selector: 'app', template: `
@defer (on timer(1s); hydrate never) {
defer block rendered! {{ value() }} @defer (on immediate; hydrate on idle) {

shouldn't be annotated

} @placeholder {

blah de blah

}
} @placeholder { Outer block placeholder } @defer (on timer(1s); hydrate on viewport) {
viewport section

has a binding

} @placeholder { another placeholder }
`, }) class SimpleComponent { value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).not.toContain('start'); expect(ssrContents).toContain('

has a binding

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

shouldn\'t be annotated

'); }); }); it('should only count and log blocks that were skipped', async () => { @Component({ selector: 'app', template: `
@defer (on viewport; hydrate on interaction) {
@defer (on viewport; hydrate on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder } @defer (on viewport) {

This should remain in the registry

} @placeholder { a second placeholder }
`, }) class SimpleComponent { fnA() {} } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}, withDebugConsole()]; 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, }); appRef.tick(); await appRef.whenStable(); verifyHasLog( appRef, 'Angular hydrated 1 component(s) and 16 node(s), 0 component(s) were skipped. 2 defer block(s) were configured to use incremental hydration.', ); }); }); describe('client side navigation', () => { beforeEach(() => { // This test emulates client-side behavior, set global server mode flag to `false`. globalThis['ngServerMode'] = false; TestBed.configureTestingModule({ providers: [ {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, provideClientHydration(withIncrementalHydration()), ], }); }); afterEach(() => { globalThis['ngServerMode'] = undefined; }); it('should not try to hydrate in CSR only cases', async () => { @Component({ selector: 'app', template: ` @defer (hydrate when true; on interaction) {

Defer block rendered!

} @placeholder { Outer block placeholder } `, }) class SimpleComponent {} const fixture = TestBed.createComponent(SimpleComponent); fixture.detectChanges(); // Verify that `hydrate when true` doesn't trigger rendering of the main // content in client-only use-cases (expecting to see placeholder content). expect(fixture.nativeElement.innerHTML).toContain('Outer block placeholder'); }); }); describe('control flow', () => { let pollingInterval: ReturnType; afterEach(() => { if (pollingInterval !== undefined) clearInterval(pollingInterval); }); it('should support hydration for all items in a for loop', async () => { @Component({ selector: 'app', template: `
@defer (on interaction; hydrate on interaction) {

Main defer block rendered!

@for (item of items; track $index) { @defer (on interaction; hydrate on interaction) {
defer block {{ item }} rendered! {{ value() }}
} @placeholder { Outer block placeholder } }
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); items = [1, 2, 3, 4, 5, 6]; fnA() {} fnB() { this.value.set('end'); } registry = inject(DEHYDRATED_BLOCK_REGISTRY); private readonly doc = inject(DOCUMENT); constructor() { // TODO: Understand why this is needed to get the full rendering of the HTML // Without it, bindings aren't properly rendered in SSR and the test fails. // There was no issue with the zone based scheduler. const pendingTasks = inject(PendingTasks); pendingTasks.run( () => new Promise((resolve) => { pollingInterval = setInterval(() => { const el = this.doc.getElementById('item-1'); if (el && el.textContent?.includes('defer block 1 rendered')) { clearInterval(pollingInterval); resolve(); } }, 10); // Fallback timeout to prevent indefinite hanging setTimeout(() => { clearInterval(pollingInterval); resolve(); }, 1000); }), ); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. //
s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('
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); const registry = compRef.instance.registry; spyOn(registry, 'cleanup').and.callThrough(); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('
Outer block placeholder'); expect(registry.cleanup).toHaveBeenCalledTimes(1); }); it('should handle hydration and cleanup when if then condition changes', async () => { @Component({ selector: 'app', template: `
@defer (on interaction; hydrate on interaction) {

Main defer block rendered!

@if (isServer) { @defer (on interaction; hydrate on interaction) {
nested defer block rendered!
} @placeholder { Outer block placeholder } } @else {

client side

}
} @placeholder { Outer block placeholder }
`, }) class SimpleComponent { value = signal('start'); isServer = isPlatformServer(inject(PLATFORM_ID)); fnA() {} fnB() { this.value.set('end'); } } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('
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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('nested defer block rendered'); const article = doc.getElementById('item')!; const clickEvent = new CustomEvent('click', {bubbles: true}); article.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain('nested defer block rendered'); expect(appHostNode.outerHTML).toContain('

client side

'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. expect(appHostNode.outerHTML).not.toContain('Outer block placeholder'); }); it('should render an error block when loading fails and cleanup the original content', async () => { @Component({ selector: 'nested-cmp', template: 'Rendering {{ block }} block.', }) class NestedCmp { @Input() block!: string; } @Component({ selector: 'app', imports: [NestedCmp], template: `
@defer (on interaction; hydrate on interaction) {
} @placeholder { Outer block placeholder } @error {

Failed to load dependencies :(

}
`, }) class SimpleComponent { @ViewChildren(NestedCmp) cmps!: QueryList; value = signal('start'); fnA() {} fnB() { this.value.set('end'); } } const deferDepsInterceptor = { intercept() { return () => [failedDynamicImport()]; }, }; const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('
client transition in this test. resetTViewsFor(SimpleComponent); //////////////////////////////// const doc = getDocument(); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [ ...providers, {provide: PLATFORM_ID, useValue: 'browser'}, {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, ], hydrationFeatures, }); const compRef = getComponentRef(appRef); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('Rendering primary block'); const article = doc.getElementById('item')!; const clickEvent = new CustomEvent('click', {bubbles: true}); article.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain('Rendering primary block'); expect(appHostNode.outerHTML).toContain('Rendering error block'); }); }); describe('cleanup', () => { it('should cleanup partial hydration blocks appropriately', async () => { @Component({ selector: 'app', template: `
@defer (on idle; hydrate on interaction) {

inside defer block

@if (isServer) { Server! } @else { Client! } } @loading { Loading... } @placeholder {

Placeholder!

}
`, }) class SimpleComponent { fnA() {} isServer = isPlatformServer(inject(PLATFORM_ID)); registry = inject(DEHYDRATED_BLOCK_REGISTRY); } const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); //
uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('
inside defer block

', ); // Outer defer block is rendered. expect(ssrContents).toContain('Server!'); // 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); const registry = compRef.instance.registry; spyOn(registry, 'cleanup').and.callThrough(); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '

inside defer block

', ); expect(appHostNode.outerHTML).toContain( 'Server!', ); const testElement = doc.getElementById('test')!; const clickEvent = new CustomEvent('click', {bubbles: true}); testElement.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).toContain('Client!'); expect(appHostNode.outerHTML).not.toContain('>Server!'); expect(registry.cleanup).toHaveBeenCalledTimes(1); }); it('should clear registry of blocks as they are hydrated', async () => { @Component({ 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 appRef.whenStable(); 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 mainBlock = doc.getElementById('main')!; const clickEvent = new CustomEvent('click', {bubbles: true}); mainBlock.dispatchEvent(clickEvent); await allPendingDynamicImports(); 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}); nested.dispatchEvent(clickEvent2); await allPendingDynamicImports(); appRef.tick(); 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 () => { @Component({ 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 appRef.whenStable(); const registry = compRef.instance.registry; const jsActionMap = compRef.instance.jsActionMap; const contract = compRef.instance.contract; spyOn(contract.instance!, '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 allPendingDynamicImports(); appRef.tick(); expect(registry.size).toBe(0); expect(jsActionMap.size).toBe(0); expect(contract.instance!.cleanUp).toHaveBeenCalled(); }); it('should clean up only one time per stack of blocks post hydration', async () => { @Component({ 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 appRef.whenStable(); 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 allPendingDynamicImports(); 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({ selector: 'app', template: `
@defer (on viewport; hydrate on interaction) {
@defer (on viewport; hydrate on interaction) {

Nested defer block

} @placeholder { Inner block placeholder }
} @placeholder { Outer block placeholder } @defer (on viewport; hydrate on hover) {

This should remain in the registry

} @placeholder { a second 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 appRef.whenStable(); const contract = compRef.instance.contract; spyOn(contract.instance!, 'cleanUp').and.callThrough(); 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. expect(registry.size).toBe(2); // jsactionmap should include all elements that have jsaction on them, in this // case, 3, due to the defer block root nodes. expect(jsActionMap.size).toBe(3); expect(registry.has('d0')).toBeTruthy(); const nested = doc.getElementById('nested')!; const clickEvent2 = new CustomEvent('click', {bubbles: true}); nested.dispatchEvent(clickEvent2); await allPendingDynamicImports(); appRef.tick(); expect(registry.size).toBe(1); 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({ 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 appRef.whenStable(); await allPendingDynamicImports(); appRef.tick(); await allPendingDynamicImports(); await appRef.whenStable(); expect(location.path()).toBe('/other/thing/stuff'); expect(appHostNode.outerHTML).toContain('

OtherCmp content

'); }); it('should trigger immediate with a lazy loaded route', async () => { @Component({ selector: 'nested-more', template: `
@defer (hydrate on immediate) {

{{ hydrated() }}

}
`, }) class NestedMoreCmp { hydrated = signal('nope'); constructor() { if (!isPlatformServer(inject(PLATFORM_ID))) { this.hydrated.set('yup'); } } } @Component({ selector: 'nested', imports: [NestedMoreCmp], template: `
@defer (hydrate on interaction) { }
`, }) class NestedCmp {} @Component({ selector: 'lazy', imports: [NestedCmp], template: ` @defer (hydrate on interaction) { } `, }) class LazyCmp {} const routes: Routes = [ { path: '', loadComponent: () => dynamicImportOf(LazyCmp, 50), }, ]; @Component({ selector: 'app', imports: [RouterOutlet], template: ` Works! `, }) class SimpleComponent { location = inject(Location); } const appId = 'custom-app-id'; const providers = [ {provide: APP_ID, useValue: appId}, {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter(routes), ] as unknown as Provider[]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain( ``, ); expect(ssrContents).toContain(`

nope

`); resetTViewsFor(SimpleComponent, LazyCmp); const doc = getDocument(); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [...providers], hydrationFeatures, }); const compRef = getComponentRef(appRef); await appRef.whenStable(); await allPendingDynamicImports(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( ``, ); expect(appHostNode.outerHTML).toContain(`

yup

`); }); }); describe('misconfiguration', () => { it('should log a warning when `withIncrementalHydration()` is missing in SSR setup', async () => { @Component({ selector: 'app', template: ` @defer (hydrate never) {
Hydrate never block
} `, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; // Empty list, `withIncrementalHydration()` is not included intentionally. const hydrationFeatures = () => []; const consoleSpy = spyOn(console, 'warn'); resetIncrementalHydrationEnabledWarnedForTests(); await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); expect(consoleSpy).toHaveBeenCalledTimes(1); expect(consoleSpy).toHaveBeenCalledWith(jasmine.stringMatching('NG0508')); }); it('should log a warning when `withIncrementalHydration()` is missing in hydration setup', async () => { @Component({ selector: 'app', template: ` @defer (hydrate never) {
Hydrate never block
} `, }) class SimpleComponent {} 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 consoleSpy = spyOn(console, 'warn'); resetIncrementalHydrationEnabledWarnedForTests(); const doc = getDocument(); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}], // Empty list, `withIncrementalHydration()` is not included intentionally. hydrationFeatures: () => [], }); expect(consoleSpy).toHaveBeenCalledTimes(1); expect(consoleSpy).toHaveBeenCalledWith(jasmine.stringMatching('NG0508')); }); }); });