/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/platform-server/test/incremental_hydration_spec.ts
3 214 строк
113 KB
SkyZeroZx
fix(core): initialize hydration triggers after late runtime activation
31 июл 2026, 22:26
31 июл 2026, 22:26
cfe2cda
Код
Авторство
О чём код?
/** * @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, ɵresetIncrementalHydrationRuntimeForTests as resetIncrementalHydrationRuntimeForTests, ɵ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, withNoIncrementalHydration, } 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<T>(type: T, timeout = 0): Promise<T> { return new Promise<T>((resolve) => { setTimeout(() => { resolve(type); }, timeout); }); } /** * Emulates a failed dynamic import promise. */ function failedDynamicImport(): Promise<void> { 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: '<button (click)="null">Click A</button>', }) class DepA {} @Component({ selector: 'dep-b', imports: [DepA], template: ` <dep-a /> <button (click)="(null)">Click B</button> `, }) class DepB {} @Component({ selector: 'app', imports: [DepB], template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div (click)="fnA()"> Main defer block rendered! @if (visible) { Defer events work! } <div id="outer-trigger" (mouseover)="showMessage()"></div> @defer (on viewport; hydrate on interaction) { <p (click)="fnA()">Nested defer block</p> <dep-b /> } @placeholder { <span>Inner block placeholder</span> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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('<main jsaction="click:;">'); // Buttons inside nested components inherit parent defer block namespace. expect(ssrContents).toContain('<button jsaction="click:;" ngb="d1">Click A</button>'); expect(ssrContents).toContain('<button jsaction="click:;" ngb="d1">Click B</button>'); expect(ssrContents).toContain('<!--ngh=d0-->'); expect(ssrContents).toContain('<!--ngh=d1-->'); }); it('should not include trigger array when only JSAction triggers are present', async () => { @Component({ selector: 'dep-a', template: '<button (click)="null">Click A</button>', }) class DepA {} @Component({ selector: 'dep-b', imports: [DepA], template: ` <dep-a /> <button (click)="(null)">Click B</button> `, }) class DepB {} @Component({ selector: 'app', imports: [DepB], template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div (click)="fnA()"> Main defer block rendered! @if (visible) { Defer events work! } <div id="outer-trigger" (mouseover)="showMessage()"></div> @defer (on viewport; hydrate on interaction) { <p (click)="fnA()">Nested defer block</p> <dep-b /> } @placeholder { <span>Inner block placeholder</span> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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: '<button (click)="null">Click A</button>', }) class DepA {} @Component({ selector: 'dep-b', imports: [DepA], template: ` <dep-a /> <button (click)="(null)">Click B</button> `, }) class DepB {} @Component({ selector: 'app', imports: [DepB], template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div (click)="fnA()"> Main defer block rendered! @if (visible) { Defer events work! } <div id="outer-trigger" (mouseover)="showMessage()"></div> @defer (on viewport; hydrate on viewport) { <p (click)="fnA()">Nested defer block</p> <dep-b /> } @placeholder { <span>Inner block placeholder</span> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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 { <span>Placeholder</span> } `, }) 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: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <article (click)="fnA()"> Main defer block rendered! @if (visible) { Defer events work! } <aside id="outer-trigger" (mouseover)="showMessage()"></aside> @defer (on viewport; hydrate on interaction) { <p (click)="fnA()">Nested defer block</p> } @placeholder { <span>Inner block placeholder</span> } </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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. // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article jsaction="click:;keydown:;" ngb="d0'); expect(ssrContents).toContain('<aside id="outer-trigger" jsaction="mouseover:;" ngb="d0'); // <p> is inside a nested defer block -> different namespace. expect(ssrContents).toContain('<p jsaction="click:;keydown:;" ngb="d1'); // There is an extra annotation in the TransferState data. expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"}}', ); // Outer defer block is rendered. expect(ssrContents).toContain('Main defer block rendered'); // Inner defer block is rendered as well. expect(ssrContents).toContain('Nested defer block'); // 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 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. // <main> no longer has `jsaction` attribute. expect(appHostNode.outerHTML).toContain('<main>'); // Elements from @defer blocks still have `jsaction` annotations, // since they were not triggered yet. expect(appHostNode.outerHTML).toContain('<article jsaction="click:;keydown:;" ngb="d0'); expect(appHostNode.outerHTML).toContain( '<aside id="outer-trigger" jsaction="mouseover:;" ngb="d0', ); expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const inner = doc.getElementById('outer-trigger')!; const clickEvent2 = new CustomEvent('mouseover', {bubbles: true}); inner.dispatchEvent(clickEvent2); await allPendingDynamicImports(); appRef.tick(); // An event was replayed after hydration, which resulted in // an `@if` block becoming active and its inner content got // rendered/ expect(appHostNode.outerHTML).toContain('Defer events work'); // All outer defer block elements no longer have `jsaction` annotations. expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;" ngb="d0'); expect(appHostNode.outerHTML).not.toContain( '<div id="outer-trigger" jsaction="mouseover:;" ngb="d0', ); // Inner defer block was not triggered, thus it retains `jsaction` attributes. expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1'); }); it('should SSR and hydrate nested `@defer` blocks', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div (click)="fnA()"> Main defer block rendered! @if (visible) { Defer events work! } <div id="outer-trigger" (mouseover)="showMessage()"></div> @defer (on viewport; hydrate on interaction) { <p (click)="showMessage()">Nested defer block</p> } @placeholder { <span>Inner block placeholder</span> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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. // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<div jsaction="click:;keydown:;" ngb="d0"'); expect(ssrContents).toContain('<div id="outer-trigger" jsaction="mouseover:;" ngb="d0"'); // <p> is inside a nested defer block -> different namespace. expect(ssrContents).toContain('<p jsaction="click:;keydown:;" ngb="d1'); // There is an extra annotation in the TransferState data. expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"}}', ); // Outer defer block is rendered. expect(ssrContents).toContain('Main defer block rendered'); // Inner defer block is rendered as well. expect(ssrContents).toContain('Nested defer block'); // 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 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. // <main> no longer has `jsaction` attribute. expect(appHostNode.outerHTML).toContain('<main>'); // Elements from @defer blocks still have `jsaction` annotations, // since they were not triggered yet. expect(appHostNode.outerHTML).toContain('<div jsaction="click:;keydown:;" ngb="d0"'); expect(appHostNode.outerHTML).toContain( '<div id="outer-trigger" jsaction="mouseover:;" ngb="d0', ); expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1"'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const inner = doc.body.querySelector('p')!; const clickEvent = new CustomEvent('click', {bubbles: true}); inner.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); // An event was replayed after hydration, which resulted in // an `@if` block becoming active and its inner content got // rendered/ expect(appHostNode.outerHTML).toContain('Defer events work'); // 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="'); }); it('should SSR and hydrate only defer blocks with hydrate syntax', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (hydrate on interaction) { <div (click)="fnA()"> Main defer block rendered! @if (visible) { Defer events work! } <div id="outer-trigger" (mouseover)="showMessage()"></div> @defer (on interaction) { <p (click)="showMessage()">Nested defer block</p> } @placeholder { <span>Inner block placeholder</span> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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. // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<div jsaction="click:;keydown:;" ngb="d0"'); expect(ssrContents).toContain('<div id="outer-trigger" jsaction="mouseover:;" ngb="d0"'); // <p> is inside a nested defer block -> different namespace. // expect(ssrContents).toContain('<p jsaction="click:;" ngb="d1'); // There is an extra annotation in the TransferState data. expect(ssrContents).toContain('"__nghDeferData__":{"d0":{"r":1,"s":2}}'); // Outer defer block is rendered. expect(ssrContents).toContain('Main defer block rendered'); // Inner defer block should only display placeholder. expect(ssrContents).toContain('Inner block placeholder'); // 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 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. // <main> no longer has `jsaction` attribute. expect(appHostNode.outerHTML).toContain('<main>'); // Elements from @defer blocks still have `jsaction` annotations, // since they were not triggered yet. expect(appHostNode.outerHTML).toContain('<div jsaction="click:;keydown:;" ngb="d0"'); expect(appHostNode.outerHTML).toContain( '<div id="outer-trigger" jsaction="mouseover:;" ngb="d0', ); // expect(appHostNode.outerHTML).toContain('<p jsaction="click:;" ngb="d1"'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const inner = doc.getElementById('outer-trigger')!; const clickEvent2 = new CustomEvent('mouseover', {bubbles: true}); inner.dispatchEvent(clickEvent2); await allPendingDynamicImports(); appRef.tick(); const innerParagraph = doc.body.querySelector('p')!; expect(innerParagraph).toBeUndefined(); // An event was replayed after hydration, which resulted in // an `@if` block becoming active and its inner content got // rendered/ expect(appHostNode.outerHTML).toContain('Defer events work'); expect(appHostNode.outerHTML).toContain('Inner block placeholder'); // 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="'); }); }); describe('transfer state for nested defer blocks', () => { it('should have correct transfer state data for 2-level nested defer blocks', async () => { @Component({ selector: 'app', template: ` @defer (on viewport; hydrate on interaction) { <div> Level 1 @defer (on viewport; hydrate on interaction) { <div>Level 2</div> } @placeholder { <span>Level 2 placeholder</span> } </div> } @placeholder { <span>Level 1 placeholder</span> } `, }) 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 expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"}}', ); }); it('should have correct transfer state data for 4-level nested defer blocks', async () => { @Component({ selector: 'app', template: ` @defer (on viewport; hydrate on interaction) { <div> Level 1 @defer (on viewport; hydrate on interaction) { <div> Level 2 @defer (on viewport; hydrate on interaction) { <div> Level 3 @defer (on viewport; hydrate on interaction) { <div>Level 4</div> } @placeholder { <span>Level 4 placeholder</span> } </div> } @placeholder { <span>Level 3 placeholder</span> } </div> } @placeholder { <span>Level 2 placeholder</span> } </div> } @placeholder { <span>Level 1 placeholder</span> } `, }) 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) { <div> Level 1 @defer (on viewport; hydrate on viewport) { <div>Level 2</div> } @placeholder { <span>Level 2 placeholder</span> } </div> } @placeholder { <span>Level 1 placeholder</span> } `, }) 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: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <article>defer block rendered!</article> <span id="test" (click)="fnB()">{{ value() }}</span> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article jsaction="click:;keydown:;"'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article jsaction="click:;keydown:;"'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const article = doc.getElementsByTagName('article')![0]; const clickEvent = new CustomEvent('click', {bubbles: true}); article.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;keydown:;"'); }); it('keydown', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article jsaction="click:;keydown:;"'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article jsaction="click:;keydown:;"'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const article = doc.getElementsByTagName('article')![0]; const keydownEvent = new KeyboardEvent('keydown'); article.dispatchEvent(keydownEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;keydown:;"'); }); }); describe('hydrate on hover', () => { it('mouseover', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (hydrate on hover) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article jsaction="mouseenter:;mouseover:;focusin:;"'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '<article jsaction="mouseenter:;mouseover:;focusin:;"', ); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const article = doc.getElementsByTagName('article')![0]; const hoverEvent = new CustomEvent('mouseover', {bubbles: true}); article.dispatchEvent(hoverEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain( '<div jsaction="mouseenter:;mouseover:;focusin:;"', ); }); it('focusin', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (hydrate on hover) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article jsaction="mouseenter:;mouseover:;focusin:;"'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '<article jsaction="mouseenter:;mouseover:;focusin:;"', ); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const article = doc.getElementsByTagName('article')![0]; const focusEvent = new CustomEvent('focusin', {bubbles: true}); article.dispatchEvent(focusEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain( '<div jsaction="mouseenter:;mouseover:;focusin:;"', ); }); }); describe('viewport', () => { 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<Element>(); private elementsInView = new Set<Element>(); 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: ` <main (click)="fnA()"> @defer (hydrate on viewport) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<!--ngh=d0-->'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '<span id="test" jsaction="click:;" ngb="d0">start</span>', ); 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( '<span id="test" jsaction="click:;" ngb="d0">start</span>', ); MockIntersectionObserver.invokeCallbacksForElement(article, true); await allPendingDynamicImports(); const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">end</span>'); }); it('should create IntersectionObserver with the options from the `hydrate on viewport` trigger', async () => { @Component({ selector: 'app', template: ` <main> @defer (hydrate on viewport({rootMargin: '123px', threshold: 0.5})) { <article>defer block rendered!</article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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('should create an IntersectionObserver for a nested routed viewport block', async () => { @Component({ selector: 'routed', template: ` @defer (hydrate on interaction) { <main> @defer (hydrate on viewport({rootMargin: '123px', threshold: 0.5})) { <article>defer block rendered!</article> } @placeholder { <span>Inner block placeholder</span> } </main> } @placeholder { <span>Outer block placeholder</span> } `, }) class RoutedComponent {} @Component({ selector: 'app', imports: [RouterOutlet], template: '<router-outlet />', }) class SimpleComponent {} const providers = [ {provide: APP_ID, useValue: 'custom-app-id'}, {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter([{path: '', component: RoutedComponent}]), ] as unknown as Provider[]; const hydrationFeatures = () => [withIncrementalHydration()]; const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain( '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"t":[{"trigger":2,"intersectionObserverOptions":{"rootMargin":"123px","threshold":0.5}}],"p":"d0"}}', ); // Internal cleanup before we do server->client transition in this test. resetTViewsFor(SimpleComponent, RoutedComponent); // The root view has no hydration triggers, so verify that the routed view activates a // cold client runtime. resetIncrementalHydrationRuntimeForTests(); //////////////////////////////// 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: ` <main (click)="fnA()"> @defer (hydrate on immediate) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); appRef.tick(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<span id="test">start</span>'); const testElement = doc.getElementById('test')!; const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">end</span>'); }); 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. * * The mock enforces the per-bucket invariant: for a given timeout * value, at most ONE `requestIdleCallback` should be active at a * time */ let id = 0; let idleCallbacksRequested: number; let idleCallbacksInvoked: number; let idleCallbacksCancelled: number; const onIdleCallbackQueue: Map<number, IdleRequestCallback> = new Map(); // Tracks active idle callback counts per serialized options key. const activePerTimeout = new Map<string, number>(); const idToTimeout = new Map<number, string>(); function resetCounters() { idleCallbacksRequested = 0; idleCallbacksInvoked = 0; idleCallbacksCancelled = 0; activePerTimeout.clear(); idToTimeout.clear(); } resetCounters(); let nativeRequestIdleCallback: ( callback: IdleRequestCallback, options?: IdleRequestOptions, ) => number; let nativeCancelIdleCallback: (id: number) => void; const mockRequestIdleCallback = ( callback: IdleRequestCallback, options?: IdleRequestOptions, ): number => { onIdleCallbackQueue.set(id, callback); // Enforce per-bucket invariant: a given options key must not // already have an active requestIdleCallback. const optionsKey = options?.timeout != null ? `${options.timeout}` : ''; const activeCount = activePerTimeout.get(optionsKey) ?? 0; expect(activeCount) .withContext( `Expected 0 active idle callbacks for key='${optionsKey}', ` + `but found ${activeCount}. Each options bucket should have at most one.`, ) .toBe(0); activePerTimeout.set(optionsKey, activeCount + 1); idToTimeout.set(id, optionsKey); idleCallbacksRequested++; return id++; }; const mockCancelIdleCallback = (id: number) => { onIdleCallbackQueue.delete(id); // Decrement per-bucket active count. const optionsKey = idToTimeout.get(id); if (optionsKey !== undefined) { const count = activePerTimeout.get(optionsKey) ?? 0; activePerTimeout.set(optionsKey, Math.max(0, count - 1)); idToTimeout.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: ` <main (click)="fnA()"> @defer (hydrate on idle) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article>'); triggerIdleCallbacks(); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">start</span>'); const testElement = doc.getElementById('test')!; const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">end</span>'); }); it('idle with timeout', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (hydrate on idle(2500)) { <article> defer block rendered with timeout! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered with timeout'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article>'); // Verify that requestIdleCallback was called: one for default prefetch on idle, // one for hydrate on idle(2500) — each timeout value gets its own bucket. expect(idleCallbacksRequested).toBe(2); triggerIdleCallbacks(); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">start</span>'); const testElement = doc.getElementById('test')!; const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">end</span>'); }); it('should hydrate a `hydrate on idle` block that lives inside a deferred-loaded child component', async () => { @Component({ selector: 'inner-cmp', template: ` @defer (hydrate on idle) { <article> inner defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Inner placeholder</span> } `, }) class InnerCmp { value = signal('start'); fnB() { this.value.set('end'); } } @Component({ selector: 'app', imports: [InnerCmp], template: ` <main> @defer (on idle) { <inner-cmp /> } @placeholder { <span>Outer placeholder</span> } </main> `, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; const html = await ssr(SimpleComponent, {envProviders: providers}); const ssrContents = getAppContents(html); // The outer `@defer (on idle)` is a non-hydrating defer, so the server // renders its placeholder, NOT the inner component or its hydrating block. expect(ssrContents).toContain('Outer placeholder'); expect(ssrContents).not.toContain('inner defer block rendered'); // No `@defer` blocks were configured for incremental hydration in the // outer template (the `hydrate on idle` lives in a child component that // hasn't been loaded yet on the server). expect(ssrContents).not.toContain('"__nghDeferData__"'); // Internal cleanup before we do server->client transition in this test. resetTViewsFor(SimpleComponent, InnerCmp); //////////////////////////////// const doc = getDocument(); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}], }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('Outer placeholder'); // Trigger idle to load the inner component (outer `on idle`). triggerIdleCallbacks(); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain('Outer placeholder'); expect(appHostNode.outerHTML).toContain('Inner placeholder'); triggerIdleCallbacks(); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).toContain('inner defer block rendered'); expect(appHostNode.outerHTML).toContain('<span id="test">start</span>'); const testElement = doc.getElementById('test')!; testElement.dispatchEvent(new CustomEvent('click')); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span id="test">end</span>'); }); }); describe('timer', () => { class FakeTimerScheduler { add(delay: number, callback: VoidFunction) { callback(); } remove(callback: VoidFunction) { /* noop */ } } it('top level timer', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (hydrate on timer(150)) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article>'); await allPendingDynamicImports(); expect(appHostNode.outerHTML).toContain('<span id="test">start</span>'); }); it('nested timer', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div id="main" (click)="fnA()"> defer block rendered! @defer (on viewport; hydrate on timer(150)) { <article> <p id="nested">Nested defer block</p> <span id="test">{{ value() }}</span> </article> } @placeholder { <span>Inner block placeholder</span> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article>'); await allPendingDynamicImports(); expect(appHostNode.outerHTML).toContain('<span id="test">end</span>'); }); }); it('when', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (on immediate; hydrate when iSaySo()) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } <button id="hydrate-me" (click)="triggerHydration()">Click Here</button> </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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], hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); const registry = compRef.instance.registry; spyOn(registry, 'cleanup').and.callThrough(); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; const article = appHostNode.querySelector('article'); verifyNodeWasNotHydrated(article); expect(appHostNode.outerHTML).toContain( '<span id="test" jsaction="click:;" ngb="d0">start</span>', ); 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('<span id="test">start</span>'); }); it('never', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (hydrate never) { <article>defer block rendered!</article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article>'); await timeout(500); // wait for timer appRef.tick(); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain('Outer block placeholder'); }); it('defer triggers should not fire when hydrate never is used', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (on timer(1s); hydrate never) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article>'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block rendered'); // 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 appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article>'); expect(appHostNode.outerHTML).toContain('>start</span>'); await timeout(500); // wait for timer appRef.tick(); await allPendingDynamicImports(); appRef.tick(); const testElement = doc.getElementById('test')!; const clickEvent2 = new CustomEvent('click'); testElement.dispatchEvent(clickEvent2); appRef.tick(); expect(appHostNode.outerHTML).toContain('>start</span>'); expect(appHostNode.outerHTML).not.toContain('<span id="test">end</span>'); 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: ` <main (click)="fnA()"> @defer (on timer(1s); hydrate never) { <article> defer block rendered! <span id="test" (click)="fnB()">{{ value() }}</span> @defer (on immediate; hydrate on idle) { <p id="test2" (click)="fnB()">shouldn't be annotated</p> } @placeholder { <p>blah de blah</p> } </article> } @placeholder { <span>Outer block placeholder</span> } @defer (on timer(1s); hydrate on viewport) { <div> viewport section <p (click)="fnA()">has a binding</p> </div> } @placeholder { <span>another placeholder</span> } </main> `, }) 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('<span id="test" jsaction="click:;'); expect(ssrContents).toContain('<span id="test">start</span>'); 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>'); }); }); it('should only count and log blocks that were skipped', async () => { @Component({ selector: 'app', template: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div id="main" (click)="fnA()"> <aside>Main defer block rendered!</aside> @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> } @defer (on viewport) { <p>This should remain in the registry</p> } @placeholder { <span>a second placeholder</span> } </main> `, }) 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) { <p>Defer block rendered!</p> } @placeholder { <span>Outer block placeholder</span> } `, }) 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<typeof setInterval>; afterEach(() => { if (pollingInterval !== undefined) clearInterval(pollingInterval); }); it('should support hydration for all items in a for loop', async () => { @Component({ selector: 'app', template: ` <main> @defer (on interaction; hydrate on interaction) { <div id="main" (click)="fnA()"> <p>Main defer block rendered!</p> @for (item of items; track $index) { @defer (on interaction; hydrate on interaction) { <article id="item-{{ item }}"> defer block {{ item }} rendered! <span (click)="fnB()">{{ value() }}</span> </article> } @placeholder { <span>Outer block placeholder</span> } } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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<void>((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); // <main> uses "eager" `custom-app-id` namespace. // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain('<article id="item-1" jsaction="click:;keydown:;"'); // Outer defer block is rendered. expect(ssrContents).toContain('defer block 1 rendered'); // 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); const registry = compRef.instance.registry; spyOn(registry, 'cleanup').and.callThrough(); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain('<article id="item-1" jsaction="click:;keydown:;"'); // Emit an event inside of a defer block, which should result // in triggering the defer block (start loading deps, etc) and // subsequent hydration. const article = doc.getElementById('item-1')!; const clickEvent = new CustomEvent('click', {bubbles: true}); article.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).not.toContain( '<article id="item-1" jsaction="click:;keydown:;"', ); expect(appHostNode.outerHTML).not.toContain('<span>Outer block placeholder</span>'); expect(registry.cleanup).toHaveBeenCalledTimes(1); }); it('should handle hydration and cleanup when if then condition changes', async () => { @Component({ selector: 'app', template: ` <main> @defer (on interaction; hydrate on interaction) { <div id="main" (click)="fnA()"> <p>Main defer block rendered!</p> @if (isServer) { @defer (on interaction; hydrate on interaction) { <article id="item">nested defer block rendered!</article> } @placeholder { <span>Outer block placeholder</span> } } @else { <p>client side</p> } </div> } @placeholder { <span>Outer block placeholder</span> } </main> `, }) 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('<article id="item" jsaction="click:;keydown:;"'); expect(ssrContents).toContain('nested defer block rendered'); // 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 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('<p>client side</p>'); // 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('<span>Outer block placeholder</span>'); }); 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: ` <main> @defer (on interaction; hydrate on interaction) { <article id="item"> <nested-cmp [block]="'primary'" /> </article> } @placeholder { <span>Outer block placeholder</span> } @error { <p>Failed to load dependencies :(</p> <nested-cmp [block]="'error'" /> } </main> `, }) class SimpleComponent { @ViewChildren(NestedCmp) cmps!: QueryList<NestedCmp>; 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('<article id="item" jsaction="click:;keydown:;"'); expect(ssrContents).toContain('Rendering primary block'); // 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'}, {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, ], hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(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: ` <main (click)="fnA()"> @defer (on idle; hydrate on interaction) { <p id="test">inside defer block</p> @if (isServer) { <span>Server!</span> } @else { <span>Client!</span> } } @loading { <span>Loading...</span> } @placeholder { <p>Placeholder!</p> } </main> `, }) 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); // <main> uses "eager" `custom-app-id` namespace. expect(ssrContents).toContain('<main jsaction="click:;'); // <div>s inside a defer block have `d0` as a namespace. expect(ssrContents).toContain( '<p id="test" jsaction="click:;keydown:;" ngb="d0">inside defer block</p>', ); // Outer defer block is rendered. expect(ssrContents).toContain('<span jsaction="click:;keydown:;" ngb="d0">Server!</span>'); // 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); const registry = compRef.instance.registry; spyOn(registry, 'cleanup').and.callThrough(); appRef.tick(); await appRef.whenStable(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( '<p id="test" jsaction="click:;keydown:;" ngb="d0">inside defer block</p>', ); expect(appHostNode.outerHTML).toContain( '<span jsaction="click:;keydown:;" ngb="d0">Server!</span>', ); const testElement = doc.getElementById('test')!; const clickEvent = new CustomEvent('click', {bubbles: true}); testElement.dispatchEvent(clickEvent); await allPendingDynamicImports(); appRef.tick(); expect(appHostNode.outerHTML).toContain('<span>Client!</span>'); expect(appHostNode.outerHTML).not.toContain('>Server!</span>'); expect(registry.cleanup).toHaveBeenCalledTimes(1); }); it('should clear registry of blocks as they are hydrated', async () => { @Component({ 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 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: ` <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 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: ` <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 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: ` <main (click)="fnA()"> @defer (on viewport; hydrate on interaction) { <div id="main" (click)="fnA()"> <aside>Main defer block rendered!</aside> @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> } @defer (on viewport; hydrate on hover) { <p>This should remain in the registry</p> } @placeholder { <span>a second 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 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: `<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({ 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 appRef.whenStable(); await allPendingDynamicImports(); appRef.tick(); await allPendingDynamicImports(); await appRef.whenStable(); expect(location.path()).toBe('/other/thing/stuff'); expect(appHostNode.outerHTML).toContain('<p>OtherCmp content</p>'); }); it('should trigger immediate with a lazy loaded route', async () => { @Component({ selector: 'nested-more', template: ` <div> @defer (hydrate on immediate) { <button id="click-me" (click)="clickMe()">Click me I'm dehydrated?</button> <p id="hydrated">{{ hydrated() }}</p> } </div> `, }) class NestedMoreCmp { hydrated = signal('nope'); constructor() { if (!isPlatformServer(inject(PLATFORM_ID))) { this.hydrated.set('yup'); } } } @Component({ selector: 'nested', imports: [NestedMoreCmp], template: ` <div> @defer (hydrate on interaction) { <nested-more /> } </div> `, }) class NestedCmp {} @Component({ selector: 'lazy', imports: [NestedCmp], template: ` @defer (hydrate on interaction) { <nested /> } `, }) class LazyCmp {} const routes: Routes = [ { path: '', loadComponent: () => dynamicImportOf(LazyCmp, 50), }, ]; @Component({ selector: 'app', imports: [RouterOutlet], template: ` Works! <router-outlet /> `, }) 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( `<button id="click-me" jsaction="click:;" ngb="d2">Click me I'm dehydrated?</button>`, ); expect(ssrContents).toContain(`<p id="hydrated">nope</p>`); resetTViewsFor(SimpleComponent, LazyCmp); // The immediate trigger is nested in a lazy route, so verify that it is discovered with a // cold client runtime. resetIncrementalHydrationRuntimeForTests(); const doc = getDocument(); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [...providers], hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); await appRef.whenStable(); await allPendingDynamicImports(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.outerHTML).toContain( `<button id="click-me">Click me I'm dehydrated?</button>`, ); expect(appHostNode.outerHTML).toContain(`<p id="hydrated">yup</p>`); }); }); describe('misconfiguration', () => { it('should log a warning when incremental hydration is disabled in SSR setup', async () => { @Component({ selector: 'app', template: ` @defer (hydrate never) { <div>Hydrate never block</div> } `, }) class SimpleComponent {} const appId = 'custom-app-id'; const providers = [{provide: APP_ID, useValue: appId}]; // Explicitly disabled using withNoIncrementalHydration() const hydrationFeatures = () => [withNoIncrementalHydration()]; 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 incremental hydration is disabled in hydration setup', async () => { @Component({ selector: 'app', template: ` @defer (hydrate never) { <div>Hydrate never block</div> } `, }) 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'}], // Explicitly disabled using withNoIncrementalHydration() hydrationFeatures: () => [withNoIncrementalHydration()], }); expect(consoleSpy).toHaveBeenCalledTimes(1); expect(consoleSpy).toHaveBeenCalledWith(jasmine.stringMatching('NG0508')); }); }); });