/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/platform-server/test/full_app_hydration_spec.ts
7 945 строк
274 KB
Suraj Yadav
test(platform-server): add SSR integration test for null and normal input values
03 авг 2026, 19:30
03 авг 2026, 19:30
fb698d1
Код
Авторство
О чём код?
/** * @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 '@angular/localize/init'; import { CommonModule, DOCUMENT, isPlatformBrowser, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet, PlatformLocation, } from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; import {computeMsgId} from '@angular/compiler'; import { afterEveryRender, ApplicationRef, ChangeDetectionStrategy, ChangeDetectorRef, ɵCLIENT_RENDER_MODE_FLAG as CLIENT_RENDER_MODE_FLAG, Component, ContentChild, ContentChildren, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, inject, Input, NgZone, PendingTasks, Pipe, PipeTransform, PLATFORM_ID, Provider, provideZoneChangeDetection, QueryList, signal, TemplateRef, ViewChild, ViewContainerRef, ViewEncapsulation, } from '@angular/core'; import {TestBed} from '@angular/core/testing'; import {clearTranslations, loadTranslations} from '@angular/localize'; import {withI18nSupport} from '@angular/platform-browser'; import {provideRouter, RouterOutlet, Routes} from '@angular/router'; import { clearDocument, getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor, stripUtilAttributes, } from './dom_utils'; import { clearConsole, EMPTY_TEXT_NODE_COMMENT, getComponentRef, getHydrationInfoFromTransferState, NGH_ATTR_NAME, resetNgDevModeCounters, ssr, stripExcessiveSpaces, stripSsrIntegrityMarker, stripTransferDataScript, TEXT_NODE_SEPARATOR_COMMENT, TRANSFER_STATE_TOKEN_ID, verifyAllChildNodesClaimedForHydration, verifyAllNodesClaimedForHydration, verifyClientAndSSRContentsMatch, verifyEmptyConsole, verifyHasLog, verifyHasNoLog, verifyNodeHasMismatchInfo, verifyNodeHasSkipHydrationMarker, verifyNoNodesWereClaimedForHydration, withDebugConsole, withNoopErrorHandler, } from './hydration_utils'; describe('platform-server full application 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; }); beforeEach(() => { resetNgDevModeCounters(); }); afterEach(() => { destroyPlatform(); window._ejsas = {}; }); describe('hydration', () => { let doc: Document; beforeEach(() => { doc = TestBed.inject(DOCUMENT); clearConsole(TestBed.inject(ApplicationRef)); }); afterEach(() => { clearDocument(doc); clearConsole(TestBed.inject(ApplicationRef)); }); describe('annotations', () => { it('should add hydration annotations to component host nodes during ssr', async () => { @Component({ selector: 'nested', template: 'This is a nested component.', }) class NestedComponent {} @Component({ selector: 'app', imports: [NestedComponent], template: ` <nested /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); expect(ssrContents).toContain(`<nested ${NGH_ATTR_NAME}`); }); it('should skip local ref slots while producing hydration annotations', async () => { @Component({ selector: 'nested', template: 'This is a nested component.', }) class NestedComponent {} @Component({ selector: 'app', imports: [NestedComponent], template: ` <div #localRef></div> <nested /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); expect(ssrContents).toContain(`<nested ${NGH_ATTR_NAME}`); }); it('should skip embedded views from an ApplicationRef during annotation', async () => { @Component({ selector: 'app', template: ` <ng-template #tmpl>Hi!</ng-template> `, }) class SimpleComponent { @ViewChild('tmpl', {read: TemplateRef}) tmplRef!: TemplateRef<unknown>; private appRef = inject(ApplicationRef); ngAfterViewInit() { const viewRef = this.tmplRef.createEmbeddedView({}); this.appRef.attachView(viewRef); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); }); }); describe('server rendering', () => { it('should wipe out existing host element content when server side rendering', async () => { @Component({ selector: 'app', template: ` <div>Some content</div> `, }) class SimpleComponent {} const extraChildNodes = '<!--comment--> Some text! <b>and a tag</b>'; const doc = `<html><head></head><body><app>${extraChildNodes}</app></body></html>`; const html = await ssr(SimpleComponent, {doc}); const ssrContents = getAppContents(html); // We expect that the existing content of the host node is fully removed. expect(ssrContents).not.toContain(extraChildNodes); expect(ssrContents).toContain('<app ngh="0"><div>Some content</div></app>'); }); it('should serialize input values correctly for both null and normal non-empty values during SSR', async () => { @Component({ selector: 'app', template: ` <input id="input-null" [value]="nullValue" /> <input id="input-normal" [value]="normalValue" /> `, }) class AppComponent { nullValue: string | null = null; normalValue = 'hello'; } const html = await ssr(AppComponent); const ssrContents = getAppContents(html); expect(ssrContents).not.toContain('value="null"'); expect(ssrContents).toContain('id="input-normal" value="hello"'); }); }); describe('hydration', () => { it('should remove ngh attributes after hydration on the client', async () => { @Component({ selector: 'app', template: 'Hi!', }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const appHostNode = compRef.location.nativeElement; expect(appHostNode.getAttribute(NGH_ATTR_NAME)).toBeNull(); }); describe('basic scenarios', () => { it('should support text-only contents', async () => { @Component({ selector: 'app', template: ` This is hydrated content. `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate root components with empty templates', async () => { @Component({ selector: 'app', template: '', }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate child components with empty templates', async () => { @Component({ selector: 'child', template: '', }) class ChildComponent {} @Component({ imports: [ChildComponent], selector: 'app', template: '<child />', }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, ChildComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support a single text interpolation', async () => { @Component({ selector: 'app', template: ` {{ text }} `, }) class SimpleComponent { text = 'text'; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support text and HTML elements', async () => { @Component({ selector: 'app', template: ` <header>Header</header> <main>This is hydrated content in the main element.</main> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support text and HTML elements in nested components', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div>This is the content of a nested component</div> `, }) class NestedComponent {} @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp /> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withDebugConsole()], }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // Make sure there are no extra logs in case // default NgZone is setup for an application. verifyHasNoLog( appRef, 'NG05000: Angular detected that hydration was enabled for an application ' + 'that uses a custom or a noop Zone.js implementation.', ); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support elements with local refs', async () => { @Component({ selector: 'app', template: ` <header #headerRef>Header</header> <main #mainRef>This is hydrated content in the main element.</main> <footer #footerRef>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should handle extra child nodes within a root app component', async () => { @Component({ selector: 'app', template: ` <div>Some content</div> `, }) class SimpleComponent {} const extraChildNodes = '<!--comment--> Some text! <b>and a tag</b>'; const docContent = `<html><head></head><body><app>${extraChildNodes}</app></body></html>`; const html = await ssr(SimpleComponent, {doc: docContent}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('ng-container', () => { it('should support empty containers', async () => { @Component({ selector: 'app', template: ` This is an empty container: <ng-container></ng-container> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support non-empty containers', async () => { @Component({ selector: 'app', template: ` This is a non-empty container: <ng-container> <h1>Hello world!</h1> </ng-container> <div>Post-container element</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support nested containers', async () => { @Component({ selector: 'app', template: ` This is a non-empty container: <ng-container> <ng-container> <ng-container> <h1>Hello world!</h1> </ng-container> </ng-container> </ng-container> <div>Post-container element</div> <ng-container> <div>Tags between containers</div> <ng-container> <div>More tags between containers</div> <ng-container> <h1>Hello world!</h1> </ng-container> </ng-container> </ng-container> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support element containers with *ngIf', async () => { @Component({ selector: 'cmp', template: 'Hi!', }) class Cmp {} @Component({ selector: 'app', imports: [NgIf], template: ` <ng-container *ngIf="true"> <div #inner></div> </ng-container> <ng-template #outer /> `, }) class SimpleComponent { @ViewChild('inner', {read: ViewContainerRef}) inner!: ViewContainerRef; @ViewChild('outer', {read: ViewContainerRef}) outer!: ViewContainerRef; ngAfterViewInit() { this.inner.createComponent(Cmp); this.outer.createComponent(Cmp); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, Cmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('view containers', () => { describe('*ngIf', () => { it('should work with *ngIf on ng-container nodes', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` This is a non-empty container: <ng-container *ngIf="true"> <h1>Hello world!</h1> </ng-container> <div>Post-container element</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with empty containers on ng-container nodes', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` This is an empty container: <ng-container *ngIf="false" /> <div>Post-container element</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with *ngIf on element nodes', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <h1 *ngIf="true">Hello world!</h1> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with empty containers on element nodes', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <h1 *ngIf="false">Hello world!</h1> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with *ngIf on component host nodes', async () => { @Component({ selector: 'nested-cmp', imports: [NgIf], template: ` <h1 *ngIf="true">Hello World!</h1> `, }) class NestedComponent {} @Component({ selector: 'app', imports: [NgIf, NestedComponent], template: ` This is a component: <nested-cmp *ngIf="true" /> <div>Post-container element</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support nested *ngIfs', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` This is a non-empty container: <ng-container *ngIf="true"> <h1 *ngIf="true"> <span *ngIf="true">Hello world!</span> </h1> </ng-container> <div>Post-container element</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('*ngFor', () => { it('should support *ngFor on <ng-container> nodes', async () => { @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <ng-container *ngFor="let item of items"> <h1 *ngIf="true">Item #{{ item }}</h1> </ng-container> <div>Post-container element</div> `, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Check whether serialized hydration info has a multiplier // (which avoids repeated views serialization). const hydrationInfo = getHydrationInfoFromTransferState(ssrContents); expect(hydrationInfo).toContain('"x":3'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support *ngFor on element nodes', async () => { @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div *ngFor="let item of items"> <h1 *ngIf="true">Item #{{ item }}</h1> </div> <div>Post-container element</div> `, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Check whether serialized hydration info has a multiplier // (which avoids repeated views serialization). const hydrationInfo = getHydrationInfoFromTransferState(ssrContents); expect(hydrationInfo).toContain('"x":3'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support *ngFor on host component nodes', async () => { @Component({ selector: 'nested-cmp', imports: [NgIf], template: ` <h1 *ngIf="true">Hello World!</h1> `, }) class NestedComponent {} @Component({ selector: 'app', imports: [NgIf, NgFor, NestedComponent], template: ` <nested-cmp *ngFor="let item of items" /> <div>Post-container element</div> `, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Check whether serialized hydration info has a multiplier // (which avoids repeated views serialization). const hydrationInfo = getHydrationInfoFromTransferState(ssrContents); expect(hydrationInfo).toContain('"x":3'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support compact serialization for *ngFor', async () => { @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div *ngFor="let number of numbers"> Number {{ number }} <ng-container *ngIf="number >= 0 && number < 5">is in [0, 5) range.</ng-container> <ng-container *ngIf="number >= 5 && number < 8">is in [5, 8) range.</ng-container> <ng-container *ngIf="number >= 8 && number < 10" >is in [8, 10) range.</ng-container > </div> `, }) class SimpleComponent { numbers = [...Array(10).keys()]; // [0..9] } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Check whether serialized hydration info has multipliers // (which avoids repeated views serialization). const hydrationInfo = getHydrationInfoFromTransferState(ssrContents); expect(hydrationInfo).toContain('"x":5'); // [0, 5) range, 5 views expect(hydrationInfo).toContain('"x":3'); // [5, 8) range, 3 views expect(hydrationInfo).toContain('"x":2'); // [8, 10) range, 2 views resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('*ngComponentOutlet', () => { it('should support hydration on <ng-container> nodes', async () => { @Component({ selector: 'nested-cmp', imports: [NgIf], template: ` <h1 *ngIf="true">Hello World!</h1> `, }) class NestedComponent {} @Component({ selector: 'app', imports: [NgComponentOutlet], template: ` <ng-container *ngComponentOutlet="NestedComponent" />`, }) class SimpleComponent { // This field is necessary to expose // the `NestedComponent` to the template. NestedComponent = NestedComponent; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support hydration on element nodes', async () => { @Component({ selector: 'nested-cmp', imports: [NgIf], template: ` <h1 *ngIf="true">Hello World!</h1> `, }) class NestedComponent {} @Component({ selector: 'app', imports: [NgComponentOutlet], template: ` <div *ngComponentOutlet="NestedComponent"></div> `, }) class SimpleComponent { // This field is necessary to expose // the `NestedComponent` to the template. NestedComponent = NestedComponent; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support hydration for nested components', async () => { @Component({ selector: 'nested-cmp', imports: [NgIf], template: ` <h1 *ngIf="true">Hello World!</h1> `, }) class NestedComponent {} @Component({ selector: 'other-nested-cmp', imports: [NgComponentOutlet], template: ` <ng-container *ngComponentOutlet="NestedComponent" />`, }) class OtherNestedComponent { // This field is necessary to expose // the `NestedComponent` to the template. NestedComponent = NestedComponent; } @Component({ selector: 'app', imports: [NgComponentOutlet], template: ` <ng-container *ngComponentOutlet="OtherNestedComponent" />`, }) class SimpleComponent { // This field is necessary to expose // the `OtherNestedComponent` to the template. OtherNestedComponent = OtherNestedComponent; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, NestedComponent, OtherNestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('*ngTemplateOutlet', () => { it('should work with <ng-container>', async () => { @Component({ selector: 'app', imports: [NgTemplateOutlet], template: ` <ng-template #tmpl> This is a content of the template! </ng-template> <ng-container [ngTemplateOutlet]="tmpl"></ng-container> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with element nodes', async () => { @Component({ selector: 'app', imports: [NgTemplateOutlet], template: ` <ng-template #tmpl> This is a content of the template! </ng-template> <div [ngTemplateOutlet]="tmpl"></div> <div>Some extra content</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('ViewContainerRef', () => { it('should work with ViewContainerRef.createComponent', async () => { @Component({ selector: 'dynamic', template: ` <span>This is a content of a dynamic component.</span> `, }) class DynamicComponent {} @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div #target></div> <main>Hi! This is the main content.</main> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with ViewContainerRef.createEmbeddedView', async () => { @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <ng-template #tmpl> <h1>This is a content of an ng-template.</h1> </ng-template> <ng-container #target></ng-container> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef<unknown>; ngAfterViewInit() { const viewRef = this.vcr.createEmbeddedView(this.tmpl); viewRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate dynamically created components using root component as an anchor', async () => { @Component({ imports: [CommonModule], selector: 'dynamic', template: ` <span>This is a content of a dynamic component.</span> `, }) class DynamicComponent {} @Component({ selector: 'app', template: ` <main>Hi! This is the main content.</main> `, }) class SimpleComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // Compare output starting from the parent node above the component node, // because component host node also acted as a ViewContainerRef anchor, // thus there are elements after this node (as next siblings). const clientRootNode = compRef.location.nativeElement.parentNode; await appRef.whenStable(); verifyAllChildNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate embedded views when using root component as an anchor', async () => { @Component({ selector: 'app', template: ` <ng-template #tmpl> <h1>Content of embedded view</h1> </ng-template> <main>Hi! This is the main content.</main> `, }) class SimpleComponent { @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef<unknown>; vcr = inject(ViewContainerRef); ngAfterViewInit() { const viewRef = this.vcr.createEmbeddedView(this.tmpl); viewRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // Compare output starting from the parent node above the component node, // because component host node also acted as a ViewContainerRef anchor, // thus there are elements after this node (as next siblings). const clientRootNode = compRef.location.nativeElement.parentNode; await appRef.whenStable(); verifyAllChildNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate dynamically created components using root component as an anchor (with nested components)', async () => { @Component({ imports: [CommonModule], selector: 'nested-dynamic-a', template: ` <p>NestedDynamicComponentA</p> `, }) class NestedDynamicComponentA {} @Component({ imports: [CommonModule], selector: 'nested-dynamic-b', template: ` <p>NestedDynamicComponentB</p> `, }) class NestedDynamicComponentB {} @Component({ imports: [CommonModule], selector: 'dynamic', template: ` <span>This is a content of a dynamic component.</span> `, }) class DynamicComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(NestedDynamicComponentB); compRef.changeDetectorRef.detectChanges(); } } @Component({ selector: 'app', template: ` <main>Hi! This is the main content.</main> `, }) class SimpleComponent { doc = inject(DOCUMENT); appRef = inject(ApplicationRef); elementRef = inject(ElementRef); viewContainerRef = inject(ViewContainerRef); environmentInjector = inject(EnvironmentInjector); createOuterDynamicComponent() { const hostElement = this.doc.body.querySelector('[id=dynamic-cmp-target]')!; const compRef = createComponent(DynamicComponent, { hostElement, environmentInjector: this.environmentInjector, }); compRef.changeDetectorRef.detectChanges(); this.appRef.attachView(compRef.hostView); } createInnerDynamicComponent() { const compRef = this.viewContainerRef.createComponent(NestedDynamicComponentA); compRef.changeDetectorRef.detectChanges(); } ngAfterViewInit() { this.createInnerDynamicComponent(); this.createOuterDynamicComponent(); } } // In this test we expect to have the following structure, // where both root component nodes also act as ViewContainerRef // anchors, i.e.: // ``` // <app /> // <nested-dynamic-b /> // <!--container--> // <div></div> // Host element for DynamicComponent // <nested-dynamic-a/> // <!--container--> // ``` // The test verifies that 2 root components acting as ViewContainerRef // do not have overlaps in DOM elements that represent views and all // DOM nodes are able to hydrate correctly. const indexHtml = '<html><head></head><body>' + '<app></app>' + '<div id="dynamic-cmp-target"></div>' + '</body></html>'; const html = await ssr(SimpleComponent, {doc: indexHtml}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // Compare output starting from the parent node above the component node, // because component host node also acted as a ViewContainerRef anchor, // thus there are elements after this node (as next siblings). const clientRootNode = compRef.location.nativeElement.parentNode; await appRef.whenStable(); verifyAllChildNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should hydrate dynamically created components using ' + "another component's host node as an anchor", async () => { @Component({ selector: 'another-dynamic', template: `<span>This is a content of another dynamic component.</span>`, }) class AnotherDynamicComponent { vcr = inject(ViewContainerRef); } @Component({ selector: 'dynamic', template: `<span>This is a content of a dynamic component.</span>`, }) class DynamicComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(AnotherDynamicComponent); compRef.changeDetectorRef.detectChanges(); } } @Component({ selector: 'app', template: `<main>Hi! This is the main content.</main>`, }) class SimpleComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // Compare output starting from the parent node above the component node, // because component host node also acted as a ViewContainerRef anchor, // thus there are elements after this node (as next siblings). const clientRootNode = compRef.location.nativeElement.parentNode; await appRef.whenStable(); verifyAllChildNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should hydrate dynamically created embedded views using ' + "another component's host node as an anchor", async () => { @Component({ selector: 'dynamic', template: ` <ng-template #tmpl> <h1>Content of an embedded view</h1> </ng-template> <main>Hi! This is the dynamic component content.</main> `, }) class DynamicComponent { @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef<unknown>; vcr = inject(ViewContainerRef); ngAfterViewInit() { const viewRef = this.vcr.createEmbeddedView(this.tmpl); viewRef.detectChanges(); } } @Component({ selector: 'app', template: `<main>Hi! This is the main content.</main>`, }) class SimpleComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // Compare output starting from the parent node above the component node, // because component host node also acted as a ViewContainerRef anchor, // thus there are elements after this node (as next siblings). const clientRootNode = compRef.location.nativeElement.parentNode; await appRef.whenStable(); verifyAllChildNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should re-create the views from the ViewContainerRef ' + 'if there is a mismatch in template ids between the current view ' + '(that is being created) and the first dehydrated view in the list', async () => { @Component({ selector: 'app', template: ` <ng-template #tmplH1> <h1>Content of H1</h1> </ng-template> <ng-template #tmplH2> <h2>Content of H2</h2> </ng-template> <ng-template #tmplH3> <h3>Content of H3</h3> </ng-template> <p>Pre-container content</p> <ng-container #target></ng-container> <div>Post-container content</div> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; @ViewChild('tmplH1', {read: TemplateRef}) tmplH1!: TemplateRef<unknown>; @ViewChild('tmplH2', {read: TemplateRef}) tmplH2!: TemplateRef<unknown>; @ViewChild('tmplH3', {read: TemplateRef}) tmplH3!: TemplateRef<unknown>; isServer = isPlatformServer(inject(PLATFORM_ID)); ngAfterViewInit() { const viewRefH1 = this.vcr.createEmbeddedView(this.tmplH1); const viewRefH2 = this.vcr.createEmbeddedView(this.tmplH2); const viewRefH3 = this.vcr.createEmbeddedView(this.tmplH3); viewRefH1.detectChanges(); viewRefH2.detectChanges(); viewRefH3.detectChanges(); // Move the last view in front of the first one. this.vcr.move(viewRefH3, 0); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); // We expect that all 3 dehydrated views would be removed // (each dehydrated view represents a real embedded view), // since we can not hydrate them in order (views were // moved in a container). expect(ngDevMode!.dehydratedViewsRemoved).toBe(3); const clientRootNode = compRef.location.nativeElement; const h1 = clientRootNode.querySelector('h1'); const h2 = clientRootNode.querySelector('h2'); const h3 = clientRootNode.querySelector('h3'); const exceptions = [h1, h2, h3]; verifyAllNodesClaimedForHydration(clientRootNode, exceptions); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should allow injecting ViewContainerRef in the root component', async () => { @Component({ selector: 'app', template: `Hello World!`, }) class SimpleComponent { private vcRef = inject(ViewContainerRef); } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); // Replace the trailing comment node (added as a result of the // `ViewContainerRef` injection) before comparing contents. const _ssrContents = ssrContents.replace(/<\/app><!--container-->/, '</app>'); verifyClientAndSSRContentsMatch(_ssrContents, clientRootNode); }); }); describe('<ng-template>', () => { it('should support unused <ng-template>s', async () => { @Component({ selector: 'app', template: ` <ng-template #a>Some content</ng-template> <div>Tag in between</div> <ng-template #b>Some content</ng-template> <p>Tag in between</p> <ng-template #c> Some content <ng-template #d> Nested template content. </ng-template> </ng-template> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('transplanted views', () => { it('should work when passing TemplateRef to a different component', async () => { @Component({ imports: [CommonModule], selector: 'insertion-component', template: ` <ng-container [ngTemplateOutlet]="template"></ng-container> `, }) class InsertionComponent { @Input() template!: TemplateRef<unknown>; } @Component({ selector: 'app', imports: [InsertionComponent, CommonModule], template: ` <ng-template #template> This is a transplanted view! <div *ngIf="true">With more nested views!</div> </ng-template> <insertion-component [template]="template" /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); }); }); describe('i18n', () => { describe('support is enabled', () => { afterEach(() => { clearTranslations(); }); it('should append skip hydration flag if component uses i18n blocks and no `withI18nSupport()` call present', async () => { @Component({ selector: 'app', template: '<div i18n>Hi!</div>', }) class SimpleComponent { // Having `ViewContainerRef` here is important: it triggers // a code path that serializes top-level `LContainer`s. vcr = inject(ViewContainerRef); } const hydrationFeatures = () => []; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); // Since `withI18nSupport()` was not included and a component has i18n blocks - // we expect that the `ngSkipHydration` attribute was added during serialization. expect(ssrContents).not.toContain('ngh="'); expect(ssrContents).toContain('ngskiphydration="'); }); it('should not append skip hydration flag if component uses i18n blocks', async () => { @Component({ selector: 'app', template: ` <div i18n>Hi!</div> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); }); it('should not append skip hydration flag if component uses i18n blocks inside embedded views', async () => { @Component({ imports: [NgIf], selector: 'app', template: ` <main *ngIf="true"> <div *ngIf="true" i18n>Hi!</div> </main> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); }); it('should not append skip hydration flag if component uses i18n blocks on <ng-container>s', async () => { @Component({ selector: 'app', template: ` <ng-container i18n>Hi!</ng-container> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); }); it('should not append skip hydration flag if component uses i18n blocks (with *ngIfs on <ng-container>s)', async () => { @Component({ imports: [CommonModule], selector: 'app', template: ` <ng-container *ngIf="true" i18n>Hi!</ng-container> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); }); it('should support translations that do not include every placeholder', async () => { loadTranslations({ [computeMsgId('Some {$START_TAG_STRONG}strong{$CLOSE_TAG_STRONG} content')]: 'Some normal content', }); @Component({ selector: 'app', template: `<div i18n>Some <strong>strong</strong> content</div>`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toBe('Some normal content'); }); it('should support projecting translated content', async () => { @Component({ selector: 'app-content', template: `<ng-content select="span"></ng-content ><ng-content select="div"></ng-content>`, }) class ContentComponent {} @Component({ selector: 'app', template: `<div i18n> <app-content ><div>one</div> <span>two</span></app-content > </div>`, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toBe('<span>two</span><div>one</div>'); }); it('should work when i18n content is not projected', async () => { @Component({ selector: 'app-content', template: ` @if (false) { <ng-content /> } Content outside of 'if'. `, }) class ContentComponent {} @Component({ selector: 'app', template: ` <app-content> <div i18n>Hello!</div> <ng-container i18n>Hello again!</ng-container> </app-content> `, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); const text = content.textContent.trim(); expect(text).toBe("Content outside of 'if'."); expect(text).not.toContain('Hello'); }); it('should support interleaving projected content', async () => { @Component({ selector: 'app-content', template: `Start <ng-content select="div" /> Middle <ng-content select="span" /> End`, }) class ContentComponent {} @Component({ selector: 'app', template: ` <app-content i18n> <span>Span</span> Middle Start Middle End <div>Div</div> </app-content> `, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toBe('Start <div>Div</div> Middle <span>Span</span> End'); }); it('should support disjoint nodes', async () => { @Component({ selector: 'app-content', template: `Start <ng-content select=":not(span)" /> Middle <ng-content select="span" /> End`, }) class ContentComponent {} @Component({ selector: 'app', template: ` <app-content i18n> Inner Start <span>Span</span> {count, plural, other {Hello <span>World</span>!}} Inner End </app-content> `, imports: [ContentComponent], }) class SimpleComponent { count = 0; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toBe( 'Start Inner Start Hello <span>World</span>!<!--ICU 28:0--> Inner End Middle <span>Span</span> End', ); }); it('should support nested content projection', async () => { @Component({ selector: 'app-content-inner', template: `Start <ng-content select=":not(span)" /> Middle <ng-content select="span" /> End`, }) class InnerContentComponent {} @Component({ selector: 'app-content-outer', template: `<app-content-inner><ng-content /></app-content-inner>`, imports: [InnerContentComponent], }) class OuterContentComponent {} @Component({ selector: 'app', template: ` <app-content-outer i18n> Outer Start <span>Span</span> {count, plural, other {Hello <span>World</span>!}} Outer End </app-content-outer> `, imports: [OuterContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, OuterContentComponent, InnerContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content-outer'); expect(content.innerHTML).toBe( '<app-content-inner>Start Outer Start <span>Span</span> Hello <span>World</span>!<!--ICU 28:0--> Outer End Middle End</app-content-inner>', ); }); it('should support hosting projected content', async () => { @Component({ selector: 'app-content', template: `<span i18n>Start <ng-content /> End</span>`, }) class ContentComponent {} @Component({ selector: 'app', template: `<div><app-content>Middle</app-content></div>`, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toBe('<app-content><span>Start Middle End</span></app-content>'); }); it('should support projecting multiple elements', async () => { @Component({ selector: 'app-content', template: `<ng-content />`, }) class ContentComponent {} @Component({ selector: 'app', template: ` <app-content i18n> Start <span>Middle</span> End </app-content> `, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toMatch(/ Start <span>Middle<\/span> End /); }); it('should support disconnecting i18n nodes during projection', async () => { @Component({ selector: 'app-content', template: `Start <ng-content select="span" /> End`, }) class ContentComponent {} @Component({ selector: 'app', template: ` <app-content i18n> Middle Start <span>Middle</span> Middle End </app-content> `, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ContentComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toBe('Start <span>Middle</span> End'); }); it('should support using translated views as view container anchors', async () => { @Component({ selector: 'dynamic-cmp', template: `DynamicComponent content`, }) class DynamicComponent {} @Component({ selector: 'app', template: `<div i18n> <div #target>one</div> <span>two</span> </div>`, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); const clientContents = stripExcessiveSpaces(stripUtilAttributes(div.innerHTML, false)); expect(clientContents).toBe( '<div>one</div><dynamic-cmp>DynamicComponent content</dynamic-cmp><!--container--><span>two</span>', ); }); it('should support translations that reorder placeholders', async () => { loadTranslations({ [computeMsgId( '{$START_TAG_DIV}one{$CLOSE_TAG_DIV}{$START_TAG_SPAN}two{$CLOSE_TAG_SPAN}', )]: '{$START_TAG_SPAN}dos{$CLOSE_TAG_SPAN}{$START_TAG_DIV}uno{$CLOSE_TAG_DIV}', }); @Component({ selector: 'app', template: `<div i18n> <div>one</div> <span>two</span> </div>`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toBe('<span>dos</span><div>uno</div>'); }); it('should support translations that include additional elements', async () => { loadTranslations({ [computeMsgId('{VAR_PLURAL, plural, other {normal}}')]: '{VAR_PLURAL, plural, other {<strong>strong</strong>}}', }); @Component({ selector: 'app', template: `<div i18n>Some {case, plural, other {normal}} content</div>`, }) class SimpleComponent { case = 0; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toMatch(/Some <strong>strong<\/strong><!--ICU 28:0--> content/); }); it('should support translations that remove elements', async () => { loadTranslations({ [computeMsgId('Hello {$START_TAG_STRONG}World{$CLOSE_TAG_STRONG}!')]: 'Bonjour!', }); @Component({ selector: 'app', template: `<div i18n>Hello <strong>World</strong>!</div>`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toMatch(/Bonjour!/); }); it('should cleanup dehydrated ICU cases', async () => { @Component({ selector: 'app', template: `<div i18n> {isServer, select, true {This is a SERVER-ONLY content} false {This is a CLIENT-ONLY content} } </div>`, }) class SimpleComponent { isServer = isPlatformServer(inject(PLATFORM_ID)) + ''; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain('This is a CLIENT-ONLY content'); expect(ssrContents).toContain('This is a SERVER-ONLY content'); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('This is a CLIENT-ONLY content'); expect(clientContents).not.toContain('This is a SERVER-ONLY content'); }); it('should hydrate ICUs (simple)', async () => { @Component({ selector: 'app', template: `<div i18n> {{ firstCase }} {firstCase, plural, =1 {item} other {items}}, {{ secondCase }} {secondCase, plural, =1 {item} other {items}} </div>`, }) class SimpleComponent { firstCase = 0; secondCase = 1; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.textContent).toBe(' 0 items, 1 item '); }); it('should hydrate ICUs (nested)', async () => { @Component({ selector: 'simple-component', template: `<div i18n> {firstCase, select, 1 {one-{secondCase, select, 1 {one} 2 {two}}} 2 {two-{secondCase, select, 1 {one} 2 {two}}} } </div>`, }) class SimpleComponent { @Input() firstCase!: number; @Input() secondCase!: number; } @Component({ imports: [SimpleComponent], selector: 'app', template: ` <simple-component id="one" firstCase="1" secondCase="1"></simple-component> <simple-component id="two" firstCase="1" secondCase="2"></simple-component> <simple-component id="three" firstCase="2" secondCase="1"></simple-component> <simple-component id="four" firstCase="2" secondCase="2"></simple-component> `, }) class AppComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(AppComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(AppComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, AppComponent, { hydrationFeatures, }); const compRef = getComponentRef<AppComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.querySelector('#one').textContent).toBe(' one-one '); expect(clientRootNode.querySelector('#two').textContent).toBe(' one-two '); expect(clientRootNode.querySelector('#three').textContent).toBe(' two-one '); expect(clientRootNode.querySelector('#four').textContent).toBe(' two-two '); }); it('should hydrate containers', async () => { @Component({ selector: 'app', template: ` <ng-container i18n> Container #1 </ng-container> <ng-container i18n> Container #2 </ng-container> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML); expect(clientContents).toBe( ' Container #1 <!--ng-container--> Container #2 <!--ng-container-->', ); }); it('should hydrate when using the *ngFor directive', async () => { @Component({ imports: [NgFor], selector: 'app', template: ` <ol i18n> <li *ngFor="let item of items">{{ item }}</li> </ol> `, }) class SimpleComponent { items = [1, 2, 3]; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML); expect(clientContents).toBe('<ol><li>1</li><li>2</li><li>3</li><!--container--></ol>'); }); it('should hydrate when using @for control flow', async () => { @Component({ selector: 'app', template: ` <ol i18n> @for (item of items; track $index) { <li>{{ item }}</li> } </ol> `, }) class SimpleComponent { items = [1, 2, 3]; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML); expect(clientContents).toBe('<ol><li>1</li><li>2</li><li>3</li><!--container--></ol>'); }); describe('with ngSkipHydration', () => { it('should skip hydration when ngSkipHydration and i18n attributes are present on a same node', async () => { loadTranslations({ [computeMsgId(' Some {$START_TAG_STRONG}strong{$CLOSE_TAG_STRONG} content ')]: 'Some normal content', }); @Component({ selector: 'cmp-a', template: `<ng-content />`, }) class CmpA {} @Component({ selector: 'app', imports: [CmpA], template: ` <cmp-a i18n ngSkipHydration> Some <strong>strong</strong> content </cmp-a> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const cmpA = clientRootNode.querySelector('cmp-a'); expect(cmpA.textContent).toBe('Some normal content'); verifyNodeHasSkipHydrationMarker(cmpA); }); it('should skip hydration when i18n is inside of an ngSkipHydration block', async () => { loadTranslations({ [computeMsgId('strong')]: 'very strong', }); @Component({ selector: 'cmp-a', template: `<ng-content />`, }) class CmpA {} @Component({ selector: 'app', imports: [CmpA], template: ` <cmp-a ngSkipHydration> Some <strong i18n>strong</strong> content </cmp-a> `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { hydrationFeatures, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const cmpA = clientRootNode.querySelector('cmp-a'); expect(cmpA.textContent.trim()).toBe('Some very strong content'); verifyNodeHasSkipHydrationMarker(cmpA); }); }); }); // Note: hydration for i18n blocks is not *yet* fully supported, so the tests // below verify that components that use i18n are excluded from the hydration // by adding the `ngSkipHydration` flag onto the component host element. describe('support is disabled', () => { it('should append skip hydration flag if component uses i18n blocks', async () => { @Component({ selector: 'app', template: ` <div i18n>Hi!</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngskiphydration="">'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should keep the skip hydration flag if component uses i18n blocks', async () => { @Component({ selector: 'app', host: {ngSkipHydration: 'true'}, template: ` <div i18n>Hi!</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngskiphydration="true">'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should append skip hydration flag if component uses i18n blocks inside embedded views', async () => { @Component({ imports: [NgIf], selector: 'app', template: ` <main *ngIf="true"> <div *ngIf="true" i18n>Hi!</div> </main> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngskiphydration="">'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should append skip hydration flag if component uses i18n blocks on <ng-container>s', async () => { @Component({ selector: 'app', template: ` <ng-container i18n>Hi!</ng-container> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngskiphydration="">'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should append skip hydration flag if component uses i18n blocks (with *ngIfs on <ng-container>s)', async () => { @Component({ imports: [CommonModule], selector: 'app', template: ` <ng-container *ngIf="true" i18n>Hi!</ng-container> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngskiphydration="">'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should *not* throw when i18n attributes are used', async () => { @Component({ selector: 'app', template: ` <div i18n-title title="Hello world">Hi!</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should *not* throw when i18n is used in nested component ' + 'excluded using `ngSkipHydration`', async () => { @Component({ selector: 'nested', template: ` <div i18n>Hi!</div> `, }) class NestedComponent {} @Component({ imports: [NestedComponent], selector: 'app', template: ` Nested component with i18n inside: <nested ngSkipHydration /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should exclude components with i18n from hydration automatically', async () => { @Component({ selector: 'nested', template: ` <div i18n>Hi!</div> `, }) class NestedComponent {} @Component({ imports: [NestedComponent], selector: 'app', template: ` Nested component with i18n inside (the content of this component would be excluded from hydration): <nested /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); }); describe('defer blocks', () => { it('should not trigger defer blocks on the server', async () => { @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'app', imports: [MyLazyCmp], template: ` Visible: {{ isVisible() }}. @defer (when isVisible()) { <my-lazy-cmp /> } @loading { Loading... } @placeholder { Placeholder! } @error { Failed to load dependencies :( } `, }) class SimpleComponent { isVisible = signal(false); pendingTasks = inject(PendingTasks); ngOnInit() { const removeTask = this.pendingTasks.add(); setTimeout(() => { // This changes the triggering condition of the defer block, // but it should be ignored and the placeholder content should be visible. this.isVisible.set(true); removeTask(); }); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Even though trigger condition is `true`, // the defer block remains in the "placeholder" mode on the server. expect(ssrContents).toContain('Visible: true.'); expect(ssrContents).toContain('Placeholder'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; // This content is rendered only on the client, since it's // inside a defer block. const innerComponent = clientRootNode.querySelector('my-lazy-cmp'); const exceptions = [innerComponent]; verifyAllNodesClaimedForHydration(clientRootNode, exceptions); // Verify that defer block renders correctly after hydration and triggering // loading condition. expect(clientRootNode.outerHTML).toContain('<my-lazy-cmp>Hi!</my-lazy-cmp>'); }); it('should not trigger `setTimeout` calls for `on timer` triggers on the server', async () => { const setTimeoutSpy = spyOn(globalThis, 'setTimeout').and.callThrough(); @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} // prettier-ignore @Component({ selector: 'app', imports: [MyLazyCmp], template: ` @defer (on timer(123ms)) { <my-lazy-cmp /> } `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Make sure that there were no `setTimeout` calls with the # of ms // defined in the `on timer` trigger. for (let i = 0; i < setTimeoutSpy.calls.count(); i++) { const args = setTimeoutSpy.calls.argsFor(i); expect(args[1]).not.toBe(123, 'on timer was triggered during SSR unexpectedly'); } }); it('should hydrate a placeholder block', async () => { @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'my-placeholder-cmp', imports: [NgIf], template: '<div *ngIf="true">Hi!</div>', }) class MyPlaceholderCmp {} @Component({ selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp], template: ` Visible: {{ isVisible }}. @defer (when isVisible) { <my-lazy-cmp /> } @loading { Loading... } @placeholder { Placeholder! <my-placeholder-cmp /> } @error { Failed to load dependencies :( } `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Make sure we have placeholder contents in SSR output. expect(ssrContents).toContain('Placeholder! <my-placeholder-cmp ngh="0"><div>Hi!</div>'); resetTViewsFor(SimpleComponent, MyPlaceholderCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should render nothing on the server if no placeholder block is provided', async () => { @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'my-placeholder-cmp', imports: [NgIf], template: '<div *ngIf="true">Hi!</div>', }) class MyPlaceholderCmp {} // prettier-ignore @Component({ selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp], template: ` Before|@defer (when isVisible) {<my-lazy-cmp />}|After `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Make sure no elements from a defer block is present in SSR output. // Note: comment nodes represent main content and defer block anchors, // which is expected. expect(ssrContents).toContain('Before|<!--container--><!--container-->|After'); resetTViewsFor(SimpleComponent, MyPlaceholderCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should not reference IntersectionObserver on the server', async () => { // This test verifies that there are no errors produced while rendering on a server // when `on viewport` trigger is used for a defer block. @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'app', imports: [MyLazyCmp], template: ` @defer (when isVisible; prefetch on viewport(ref)) { <my-lazy-cmp /> } @placeholder { <div #ref>Placeholder!</div> } `, }) class SimpleComponent { isVisible = false; } const errors: string[] = []; class CustomErrorHandler extends ErrorHandler { override handleError(error: any): void { errors.push(error); } } const envProviders = [ { provide: ErrorHandler, useClass: CustomErrorHandler, }, ]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('Placeholder'); // Verify that there are no errors. expect(errors).toHaveSize(0); }); it('should not hydrate when an entire block in skip hydration section', async () => { @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'projector-cmp', template: ` <main> <ng-content /> </main> `, }) class ProjectorCmp {} @Component({ selector: 'my-placeholder-cmp', imports: [NgIf], template: '<div *ngIf="true">Hi!</div>', }) class MyPlaceholderCmp {} @Component({ selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp, ProjectorCmp], template: ` Visible: {{ isVisible }}. <projector-cmp ngSkipHydration="true"> @defer (when isVisible) { <my-lazy-cmp /> } @loading { Loading... } @placeholder { <my-placeholder-cmp /> } @error { Failed to load dependencies :( } </projector-cmp> `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Make sure we have placeholder contents in SSR output. expect(ssrContents).toContain('<my-placeholder-cmp'); resetTViewsFor(SimpleComponent, MyPlaceholderCmp, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; // Verify that placeholder nodes were not claimed for hydration, // i.e. nodes were re-created since placeholder was in skip hydration block. const placeholderCmp = clientRootNode.querySelector('my-placeholder-cmp'); verifyNoNodesWereClaimedForHydration(placeholderCmp); verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should not hydrate when a placeholder block in skip hydration section', async () => { @Component({ selector: 'my-lazy-cmp', template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'projector-cmp', template: ` <main> <ng-content /> </main> `, }) class ProjectorCmp {} @Component({ selector: 'my-placeholder-cmp', imports: [NgIf], template: '<div *ngIf="true">Hi!</div>', }) class MyPlaceholderCmp {} @Component({ selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp, ProjectorCmp], template: ` Visible: {{ isVisible }}. <projector-cmp ngSkipHydration="true"> @defer (when isVisible) { <my-lazy-cmp /> } @loading { Loading... } @placeholder { <my-placeholder-cmp ngSkipHydration="true" /> } @error { Failed to load dependencies :( } </projector-cmp> `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Make sure we have placeholder contents in SSR output. expect(ssrContents).toContain('<my-placeholder-cmp'); resetTViewsFor(SimpleComponent, MyPlaceholderCmp, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; // Verify that placeholder nodes were not claimed for hydration, // i.e. nodes were re-created since placeholder was in skip hydration block. const placeholderCmp = clientRootNode.querySelector('my-placeholder-cmp'); verifyNoNodesWereClaimedForHydration(placeholderCmp); verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('ShadowDom encapsulation', () => { it('should append skip hydration flag if component uses ShadowDom encapsulation', async () => { @Component({ selector: 'app', encapsulation: ViewEncapsulation.ShadowDom, template: `Hi!`, styles: [':host { color: red; }'], }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngskiphydration="">'); }); it( 'should append skip hydration flag if component uses ShadowDom encapsulation ' + '(but keep parent and sibling elements hydratable)', async () => { @Component({ selector: 'shadow-dom', encapsulation: ViewEncapsulation.ShadowDom, template: `ShadowDom component`, styles: [':host { color: red; }'], }) class ShadowDomComponent {} @Component({ selector: 'regular', template: `<p>Regular component</p>`, }) class RegularComponent { @Input() id?: string; } @Component({ selector: 'app', imports: [RegularComponent, ShadowDomComponent], template: ` <main>Main content</main> <regular id="1" /> <shadow-dom /> <regular id="2" /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh="0">'); expect(ssrContents).toContain('<shadow-dom ngskiphydration="">'); expect(ssrContents).toContain('<regular id="1" ngh="0">'); expect(ssrContents).toContain('<regular id="2" ngh="0">'); }, ); }); describe('ngSkipHydration', () => { it('should skip hydrating elements with ngSkipHydration attribute', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div>This is the content of a nested component</div> `, }) class NestedComponent { @Input() title = ''; } @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp [title]="someTitle" style="width:100px; height:200px; color:red" moo="car" foo="value" baz ngSkipHydration /> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should skip hydrating elements when host element ' + 'has the ngSkipHydration attribute', async () => { @Component({ selector: 'app', template: ` <main>Main content</main> `, }) class SimpleComponent {} const indexHtml = '<html><head></head><body>' + '<app ngSkipHydration></app>' + '</body></html>'; const html = await ssr(SimpleComponent, {doc: indexHtml}); const ssrContents = getAppContents(html); // No `ngh` attribute in the <app> element. expect(ssrContents).toContain('<app ngskiphydration=""><main>Main content</main></app>'); // Even though hydration was skipped at the root level, the hydration // info key and an empty array as a value are still included into the // TransferState to indicate that the server part was configured correctly. const transferState = getHydrationInfoFromTransferState(html); expect(transferState).toContain(TRANSFER_STATE_TOKEN_ID); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should allow the same component with and without hydration in the same template ' + '(when component with `ngSkipHydration` goes first)', async () => { @Component({ selector: 'nested', imports: [NgIf], template: ` <ng-container *ngIf="true">Hello world</ng-container> `, }) class Nested {} @Component({ selector: 'app', imports: [NgIf, Nested], template: ` <nested ngSkipHydration /> <nested /> <nested ngSkipHydration /> <nested /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, Nested); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should allow projecting hydrated content into components that skip hydration ' + '(view containers with embedded views as projection root nodes)', async () => { @Component({ selector: 'regular-cmp', template: ` <ng-content /> `, }) class RegularCmp {} @Component({ selector: 'deeply-nested', host: {ngSkipHydration: 'true'}, template: ` <ng-content /> `, }) class DeeplyNested {} @Component({ selector: 'deeply-nested-wrapper', host: {ngSkipHydration: 'true'}, imports: [RegularCmp], template: ` <regular-cmp> <ng-content /> </regular-cmp> `, }) class DeeplyNestedWrapper {} @Component({ selector: 'layout', imports: [DeeplyNested, DeeplyNestedWrapper], template: ` <deeply-nested> <deeply-nested-wrapper> <ng-content /> </deeply-nested-wrapper> </deeply-nested> `, }) class Layout {} @Component({ selector: 'app', imports: [NgIf, Layout], template: ` <layout> <h1 *ngIf="true">Hi!</h1> </layout> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, Layout, RegularCmp, DeeplyNested, DeeplyNestedWrapper); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should allow projecting hydrated content into components that skip hydration ' + '(view containers with components as projection root nodes)', async () => { @Component({ selector: 'dynamic-cmp', template: `DynamicComponent content`, }) class DynamicComponent {} @Component({ selector: 'regular-cmp', template: ` <ng-content /> `, }) class RegularCmp {} @Component({ selector: 'deeply-nested', host: {ngSkipHydration: 'true'}, template: ` <ng-content /> `, }) class DeeplyNested {} @Component({ selector: 'deeply-nested-wrapper', host: {ngSkipHydration: 'true'}, imports: [RegularCmp], template: ` <regular-cmp> <ng-content /> </regular-cmp> `, }) class DeeplyNestedWrapper {} @Component({ selector: 'layout', imports: [DeeplyNested, DeeplyNestedWrapper], template: ` <deeply-nested> <deeply-nested-wrapper> <ng-content /> </deeply-nested-wrapper> </deeply-nested> `, }) class Layout {} @Component({ selector: 'app', imports: [NgIf, Layout], template: ` <layout> <div #target></div> </layout> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor( SimpleComponent, Layout, DynamicComponent, RegularCmp, DeeplyNested, DeeplyNestedWrapper, ); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should allow projecting hydrated content into components that skip hydration ' + '(with ng-containers as projection root nodes)', async () => { @Component({ selector: 'regular-cmp', template: ` <ng-content /> `, }) class RegularCmp {} @Component({ selector: 'deeply-nested', host: {ngSkipHydration: 'true'}, template: ` <ng-content /> `, }) class DeeplyNested {} @Component({ selector: 'deeply-nested-wrapper', host: {ngSkipHydration: 'true'}, imports: [RegularCmp], template: ` <regular-cmp> <ng-content /> </regular-cmp> `, }) class DeeplyNestedWrapper {} @Component({ selector: 'layout', imports: [DeeplyNested, DeeplyNestedWrapper], template: ` <deeply-nested> <deeply-nested-wrapper> <ng-content /> </deeply-nested-wrapper> </deeply-nested> `, }) class Layout {} @Component({ selector: 'app', imports: [NgIf, Layout], template: ` <layout> <ng-container>Hi!</ng-container> </layout> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, Layout, RegularCmp, DeeplyNested, DeeplyNestedWrapper); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should allow the same component with and without hydration in the same template ' + '(when component without `ngSkipHydration` goes first)', async () => { @Component({ selector: 'nested', imports: [NgIf], template: ` <ng-container *ngIf="true">Hello world</ng-container> `, }) class Nested {} @Component({ selector: 'app', imports: [NgIf, Nested], template: ` <nested /> <nested ngSkipHydration /> <nested /> <nested ngSkipHydration /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent, Nested); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should hydrate when the value of an attribute is "ngskiphydration"', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div>This is the content of a nested component</div> `, }) class NestedComponent { @Input() title = ''; } @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp style="width:100px; height:200px; color:red" moo="car" foo="value" baz [title]="ngSkipHydration" /> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should skip hydrating elements with ngSkipHydration host binding', async () => { @Component({ selector: 'second-cmp', template: `<div>Not hydrated</div>`, }) class SecondCmd {} @Component({ imports: [SecondCmd], selector: 'nested-cmp', template: `<second-cmp />`, host: {ngSkipHydration: 'true'}, }) class NestedCmp {} @Component({ imports: [NestedCmp], selector: 'app', template: ` <nested-cmp /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should skip hydrating all child content of an element with ngSkipHydration attribute', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div>This is the content of a nested component</div> `, }) class NestedComponent { @Input() title = ''; } @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp ngSkipHydration> <h1>Dehydrated content header</h1> <p>This content is definitely dehydrated and could use some water.</p> </nested-cmp> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should skip hydrating when ng-containers exist and ngSkipHydration attribute is present', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div>This is the content of a nested component</div> `, }) class NestedComponent {} @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp ngSkipHydration> <ng-container> <h1>Dehydrated content header</h1> </ng-container> </nested-cmp> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withDebugConsole()], }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); verifyHasLog( appRef, 'Angular hydrated 1 component(s) and 6 node(s), 1 component(s) were skipped', ); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should skip hydrating and safely allow DOM manipulation inside block that was skipped', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div #nestedDiv>This is the content of a nested component</div> `, }) class NestedComponent { el = inject(ElementRef); ngAfterViewInit() { const span = document.createElement('span'); span.innerHTML = 'Appended span'; this.el.nativeElement.appendChild(span); } } @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp ngSkipHydration /> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.outerHTML).toContain('Appended span'); verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should skip hydrating and safely allow adding and removing DOM nodes inside block that was skipped', async () => { @Component({ selector: 'nested-cmp', template: ` <h1>Hello World!</h1> <div #nestedDiv> <p>This content will be removed</p> </div> `, }) class NestedComponent { el = inject(ElementRef); ngAfterViewInit() { document.querySelector('p')?.remove(); const span = document.createElement('span'); span.innerHTML = 'Appended span'; this.el.nativeElement.appendChild(span); } } @Component({ selector: 'app', imports: [NestedComponent], template: ` <header>Header</header> <nested-cmp ngSkipHydration /> <footer>Footer</footer> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.outerHTML).toContain('Appended span'); expect(clientRootNode.outerHTML).not.toContain('This content will be removed'); verifyAllNodesClaimedForHydration(clientRootNode); }); it('should skip hydrating elements with ngSkipHydration attribute on ViewContainerRef host', async () => { @Component({ selector: 'nested-cmp', template: `<p>Just some text</p>`, }) class NestedComponent { el = inject(ElementRef); doc = inject(DOCUMENT); ngAfterViewInit() { const pTag = this.doc.querySelector('p'); pTag?.remove(); const span = this.doc.createElement('span'); span.innerHTML = 'Appended span'; this.el.nativeElement.appendChild(span); } } @Component({ selector: 'projector-cmp', imports: [NestedComponent], template: ` <main> <nested-cmp></nested-cmp> </main> `, }) class ProjectorCmp { vcr = inject(ViewContainerRef); } @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp ngSkipHydration> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should throw when ngSkipHydration attribute is set on a node ' + 'which is not a component host', async () => { @Component({ selector: 'app', template: ` <header ngSkipHydration>Header</header> <footer ngSkipHydration>Footer</footer> `, }) class SimpleComponent {} try { const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); fail('Expected the hydration process to throw.'); } catch (e: unknown) { expect((e as Error).toString()).toContain( 'The `ngSkipHydration` flag is applied ' + "on a node that doesn't act as a component host", ); } }, ); it( 'should throw when ngSkipHydration attribute is set on a node ' + 'which is not a component host (when using host bindings)', async () => { @Directive({ selector: '[dir]', host: {ngSkipHydration: 'true'}, }) class Dir {} @Component({ selector: 'app', imports: [Dir], template: ` <div dir></div> `, }) class SimpleComponent {} try { const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); fail('Expected the hydration process to throw.'); } catch (e: unknown) { const errorMessage = (e as Error).toString(); expect(errorMessage).toContain( 'The `ngSkipHydration` flag is applied ' + "on a node that doesn't act as a component host", ); expect(errorMessage).toContain( '<div ngskiphydration="true" dir="">…</div> <-- AT THIS LOCATION', ); } }, ); }); describe('corrupted text nodes restoration', () => { it('should support empty text nodes', async () => { @Component({ selector: 'app', template: ` This is hydrated content. <span>{{ spanText }}</span >. <p>{{ pText }}</p> <div>{{ anotherText }}</div> `, }) class SimpleComponent { spanText = ''; pText = ''; anotherText = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should support empty text interpolations within elements ' + '(when interpolation is on a new line)', async () => { @Component({ selector: 'app', template: ` <div> {{ text }} </div> `, }) class SimpleComponent { text = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Expect special markers to not be present, since there // are no corrupted text nodes that require restoring. // // The HTML contents produced by the SSR would look like this: // `<div> </div>` (1 text node with 2 empty spaces inside of // a <div>), which would result in creating a text node by a // browser. expect(ssrContents).not.toContain(EMPTY_TEXT_NODE_COMMENT); expect(ssrContents).not.toContain(TEXT_NODE_SEPARATOR_COMMENT); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should not treat text nodes with ` `s as empty', async () => { @Component({ selector: 'app', template: ` <div> {{ text }} </div> <h1>Hello world!</h1> <h2>Hello world!</h2> `, }) class SimpleComponent { text = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Expect special markers to not be present, since there // are no corrupted text nodes that require restoring. expect(ssrContents).not.toContain(EMPTY_TEXT_NODE_COMMENT); expect(ssrContents).not.toContain(TEXT_NODE_SEPARATOR_COMMENT); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support restoration of multiple text nodes in a row', async () => { @Component({ selector: 'app', template: ` This is hydrated content.<span>{{ emptyText }}{{ moreText }}{{ andMoreText }}</span >. <p>{{ secondEmptyText }}{{ secondMoreText }}</p> `, }) class SimpleComponent { emptyText = ''; moreText = ''; andMoreText = ''; secondEmptyText = ''; secondMoreText = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support projected text node content with plain text nodes', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <div> Hello <ng-container *ngIf="true">Angular</ng-container> <ng-container *ngIf="true">World</ng-container> </div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('post-hydration cleanup', () => { it('should cleanup unclaimed views in a component (when using elements)', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <b *ngIf="isServer">This is a SERVER-ONLY content</b> <i *ngIf="!isServer">This is a CLIENT-ONLY content</i> `, }) class SimpleComponent { // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain('<i>This is a CLIENT-ONLY content</i>'); expect(ssrContents).toContain('<b>This is a SERVER-ONLY content</b>'); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('<i>This is a CLIENT-ONLY content</i>'); expect(clientContents).not.toContain('<b>This is a SERVER-ONLY content</b>'); }); it('should cleanup unclaimed views in a component (when using <ng-container>s)', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <ng-container *ngIf="isServer">This is a SERVER-ONLY content</ng-container> <ng-container *ngIf="!isServer">This is a CLIENT-ONLY content</ng-container> `, }) class SimpleComponent { // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain('This is a CLIENT-ONLY content<!--ng-container-->'); expect(ssrContents).toContain('This is a SERVER-ONLY content<!--ng-container-->'); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('This is a CLIENT-ONLY content<!--ng-container-->'); expect(clientContents).not.toContain('This is a SERVER-ONLY content<!--ng-container-->'); }); it( 'should cleanup unclaimed views in a view container when ' + 'root component is used as an anchor for ViewContainerRef', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <ng-template #tmpl> <span *ngIf="isServer">This is a SERVER-ONLY content (embedded view)</span> <div *ngIf="!isServer">This is a CLIENT-ONLY content (embedded view)</div> </ng-template> <b *ngIf="isServer">This is a SERVER-ONLY content (root component)</b> <i *ngIf="!isServer">This is a CLIENT-ONLY content (root component)</i> `, }) class SimpleComponent { // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef<unknown>; vcr = inject(ViewContainerRef); ngAfterViewInit() { const viewRef = this.vcr.createEmbeddedView(this.tmpl); viewRef.detectChanges(); } } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain( '<i>This is a CLIENT-ONLY content (root component)</i>', ); expect(ssrContents).not.toContain( '<div>This is a CLIENT-ONLY content (embedded view)</div>', ); expect(ssrContents).toContain('<b>This is a SERVER-ONLY content (root component)</b>'); expect(ssrContents).toContain( '<span>This is a SERVER-ONLY content (embedded view)</span>', ); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.parentNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('<i>This is a CLIENT-ONLY content (root component)</i>'); expect(clientContents).toContain( '<div>This is a CLIENT-ONLY content (embedded view)</div>', ); expect(clientContents).not.toContain( '<b>This is a SERVER-ONLY content (root component)</b>', ); expect(clientContents).not.toContain( '<span>This is a SERVER-ONLY content (embedded view)</span>', ); }, ); it('should cleanup within inner containers', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <ng-container *ngIf="true"> <b *ngIf="isServer">This is a SERVER-ONLY content</b> Outside of the container (must be retained). </ng-container> <i *ngIf="!isServer">This is a CLIENT-ONLY content</i> `, }) class SimpleComponent { // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain('<i>This is a CLIENT-ONLY content</i>'); expect(ssrContents).toContain('<b>This is a SERVER-ONLY content</b>'); expect(ssrContents).toContain('Outside of the container (must be retained).'); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('<i>This is a CLIENT-ONLY content</i>'); expect(clientContents).not.toContain('<b>This is a SERVER-ONLY content</b>'); // This line must be preserved (it's outside of the dehydrated container). expect(clientContents).toContain('Outside of the container (must be retained).'); }); it('should reconcile *ngFor-generated views', async () => { @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div> <span *ngFor="let item of items"> {{ item }} <b *ngIf="item > 15">is bigger than 15!</b> </span> <main>Hi! This is the main content.</main> </div> `, }) class SimpleComponent { isServer = isPlatformServer(inject(PLATFORM_ID)); // Note: this is needed to test cleanup/reconciliation logic. items = this.isServer ? [10, 20, 100, 200] : [30, 5, 50]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); // Post-cleanup should *not* contain dehydrated views. const postCleanupContents = stripExcessiveSpaces(clientRootNode.outerHTML); expect(postCleanupContents).not.toContain( '<span> 5 <b>is bigger than 15!</b><!--container--></span>', ); expect(postCleanupContents).toContain( '<span> 30 <b>is bigger than 15!</b><!--container--></span>', ); expect(postCleanupContents).toContain('<span> 5 <!--container--></span>'); expect(postCleanupContents).toContain( '<span> 50 <b>is bigger than 15!</b><!--container--></span>', ); }); it('should cleanup dehydrated views within dynamically created components', async () => { @Component({ imports: [CommonModule], selector: 'dynamic', template: ` <span>This is a content of a dynamic component.</span> <b *ngIf="isServer">This is a SERVER-ONLY content</b> <i *ngIf="!isServer">This is a CLIENT-ONLY content</i> <ng-container *ngIf="isServer"> This is also a SERVER-ONLY content, but inside ng-container. <b>With some extra tags</b> and some text inside. </ng-container> `, }) class DynamicComponent { isServer = isPlatformServer(inject(PLATFORM_ID)); } @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div #target></div> <main>Hi! This is the main content.</main> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; ngAfterViewInit() { const compRef = this.vcr.createComponent(DynamicComponent); compRef.changeDetectorRef.detectChanges(); } } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, DynamicComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // We expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain('<i>This is a CLIENT-ONLY content</i>'); expect(ssrContents).toContain('<b>This is a SERVER-ONLY content</b>'); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('<i>This is a CLIENT-ONLY content</i>'); expect(clientContents).not.toContain('<b>This is a SERVER-ONLY content</b>'); }); [true, false].forEach((zoneless) => { it(`should trigger ${zoneless ? 'zoneless' : 'zone'} change detection after cleanup (immediate)`, async () => { const observedChildCountLog: number[] = []; @Component({ selector: 'app', imports: [NgIf], template: ` <span *ngIf="isServer">This is a SERVER-ONLY content</span> <span *ngIf="!isServer">This is a CLIENT-ONLY content</span> `, }) class SimpleComponent { isServer = isPlatformServer(inject(PLATFORM_ID)); elementRef = inject(ElementRef); constructor() { afterEveryRender(() => { observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); }); } } const envProviders = zoneless ? [] : [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); // Before hydration expect(observedChildCountLog).toEqual([]); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); await appRef.whenStable(); if (zoneless) { // 1.) Bootstrap // 2.) After cleanup expect(observedChildCountLog).toEqual([2, 1]); } else { // afterRender should be triggered by: // 1.) Bootstrap // 2.) Microtask empty event // 3.) Stabilization + cleanup expect(observedChildCountLog).toEqual([2, 2, 1]); } }); it(`should trigger ${zoneless ? 'zoneless' : 'zone'} change detection after cleanup (deferred)`, async () => { const observedChildCountLog: number[] = []; @Component({ selector: 'app', imports: [NgIf], template: ` <span *ngIf="isServer">This is a SERVER-ONLY content</span> <span *ngIf="!isServer">This is a CLIENT-ONLY content</span> `, }) class SimpleComponent { isServer = isPlatformServer(inject(PLATFORM_ID)); elementRef = inject(ElementRef); constructor() { afterEveryRender(() => { observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); }); // Create a dummy promise to prevent stabilization inject(PendingTasks).run( async () => await new Promise<void>((resolve) => { setTimeout(resolve, 0); }), ); } } const envProviders = zoneless ? [] : [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); // Before hydration expect(observedChildCountLog).toEqual([]); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); if (zoneless) { // afterRender should be triggered by: // 1.) Bootstrap expect(observedChildCountLog).toEqual([2]); } else { // afterRender should be triggered by: // 1.) Bootstrap // 2.) Microtask empty event expect(observedChildCountLog).toEqual([2, 2]); } // Cleanup will happen when stable, it will also schedule another change detection run, so we need to wait for stability again. await appRef.whenStable(); await appRef.whenStable(); if (zoneless) { // afterRender should be triggered by: // 2.) After stablization & cleanup expect(observedChildCountLog).toEqual([2, 1]); } else { // afterRender should be triggered by: // 3.) Microtask empty event // 4.) Stabilization + cleanup expect(observedChildCountLog).toEqual([2, 2, 2, 2, 1]); } }); }); }); describe('content projection', () => { it('should project plain text', async () => { @Component({ selector: 'projector-cmp', template: ` <main> <ng-content></ng-content> </main> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> Projected content is just a plain text. </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withDebugConsole()], }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); verifyHasLog( appRef, 'Angular hydrated 2 component(s) and 5 node(s), 0 component(s) were skipped', ); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should allow re-projection of child content', async () => { @Component({ selector: 'mat-step', template: `<ng-template><ng-content /></ng-template>`, }) class MatStep { @ViewChild(TemplateRef, {static: true}) content!: TemplateRef<any>; } @Component({ selector: 'mat-stepper', imports: [NgTemplateOutlet], template: ` @for (step of steps; track step) { <ng-container [ngTemplateOutlet]="step.content" /> } `, }) class MatStepper { @ContentChildren(MatStep) steps!: QueryList<MatStep>; } @Component({ selector: 'nested-cmp', template: 'Nested cmp content', }) class NestedCmp {} @Component({ imports: [MatStepper, MatStep, NgIf, NestedCmp], selector: 'app', template: ` <mat-stepper> <mat-step>Text-only content</mat-step> <mat-step> <ng-container>Using ng-containers</ng-container> </mat-step> <mat-step> <ng-container *ngIf="true"> Using ng-containers with *ngIf </ng-container> </mat-step> <mat-step> @if (true) { Using built-in control flow (if) } </mat-step> <mat-step> <nested-cmp /> </mat-step> </mat-stepper> `, }) class App {} const html = await ssr(App); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); // Verify that elements projected without their parent nodes // use an element within the same template (at position `0` // in the test, i.e. `<mat-stepper>`) as an anchor. const hydrationInfo = getHydrationInfoFromTransferState(ssrContents); expect(hydrationInfo).toContain( '"n":{"2":"0f","4":"0fn2","7":"0fn5","9":"0fn9","11":"0fn12"}', ); resetTViewsFor(App, MatStepper, NestedCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, App); const compRef = getComponentRef<App>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should project plain text and HTML elements', async () => { @Component({ selector: 'projector-cmp', template: ` <main> <ng-content></ng-content> </main> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> Projected content is a plain text. <b>Also the content has some tags</b> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support re-projection of contents', async () => { @Component({ selector: 'reprojector-cmp', template: ` <main> <ng-content></ng-content> </main> `, }) class ReprojectorCmp {} @Component({ selector: 'projector-cmp', imports: [ReprojectorCmp], template: ` <reprojector-cmp> <b>Before</b> <ng-content></ng-content> <i>After</i> </reprojector-cmp> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> Projected content is a plain text. </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp, ReprojectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should handle multiple nodes projected in a single slot', async () => { @Component({ selector: 'projector-cmp', template: ` <ng-content select="foo" /> <ng-content select="bar" /> `, }) class ProjectorCmp {} @Component({selector: 'foo', template: ''}) class FooCmp {} @Component({selector: 'bar', template: ''}) class BarCmp {} @Component({ imports: [ProjectorCmp, FooCmp, BarCmp], selector: 'app', template: ` <projector-cmp> <foo /> <bar /> <foo /> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should handle multiple nodes projected in a single slot (different order)', async () => { @Component({ selector: 'projector-cmp', template: ` <ng-content select="foo" /> <ng-content select="bar" /> `, }) class ProjectorCmp {} @Component({selector: 'foo', template: ''}) class FooCmp {} @Component({selector: 'bar', template: ''}) class BarCmp {} @Component({ imports: [ProjectorCmp, FooCmp, BarCmp], selector: 'app', template: ` <projector-cmp> <bar /> <foo /> <bar /> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should handle empty projection slots within <ng-container>', async () => { @Component({ selector: 'projector-cmp', imports: [CommonModule], template: ` <ng-container *ngIf="true"> <ng-content select="[left]"></ng-content> <div> <ng-content select="[main]"></ng-content> </div> <ng-content select="[right]"></ng-content> </ng-container> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should handle empty projection slots within <ng-container> ' + '(when no other elements are present)', async () => { @Component({ selector: 'projector-cmp', imports: [CommonModule], template: ` <ng-container *ngIf="true"> <ng-content select="[left]"></ng-content> <ng-content select="[right]"></ng-content> </ng-container> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it( 'should handle empty projection slots within a template ' + '(when no other elements are present)', async () => { @Component({ selector: 'projector-cmp', template: ` <ng-content select="[left]"></ng-content> <ng-content select="[right]"></ng-content> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should project contents into different slots', async () => { @Component({ selector: 'projector-cmp', template: ` <div> Header slot: <ng-content select="header"></ng-content> Main slot: <ng-content select="main"></ng-content> Footer slot: <ng-content select="footer"></ng-content> <ng-content></ng-content> <!-- everything else --> </div> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> <!-- contents is intentionally randomly ordered --> <h1>H1</h1> <footer>Footer</footer> <header>Header</header> <main>Main</main> <h2>H2</h2> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should handle view container nodes that go after projection slots', async () => { @Component({ selector: 'projector-cmp', imports: [CommonModule], template: ` <ng-container *ngIf="true"> <ng-content select="[left]"></ng-content> <span *ngIf="true">{{ label }}</span> </ng-container> `, }) class ProjectorCmp { label = 'Hi'; } @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should handle view container nodes that go after projection slots ' + '(when view container host node is <ng-container>)', async () => { @Component({ selector: 'projector-cmp', imports: [CommonModule], template: ` <ng-container *ngIf="true"> <ng-content select="[left]"></ng-content> <ng-container *ngIf="true">{{ label }}</ng-container> </ng-container> `, }) class ProjectorCmp { label = 'Hi'; } @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp /> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); describe('partial projection', () => { it('should support cases when some element nodes are not projected (ng-content)', async () => { @Component({ selector: 'projector-cmp', template: ` <div> Header slot: <ng-content select="header" /> Main slot: <ng-content select="main" /> Footer slot: <ng-content select="footer" /> <!-- no "default" projection bucket --> </div> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> <!-- contents is randomly ordered for testing --> <h1>This node is not projected.</h1> <footer>Footer</footer> <header>Header</header> <main>Main</main> <h2>This node is not projected as well.</h2> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support cases when some element nodes are not projected (@if)', async () => { @Component({ selector: 'app-dropdown-content', template: `<ng-content />`, }) class DropdownContentComponent {} @Component({ selector: 'app-dropdown', template: ` @if (false) { <ng-content select="app-dropdown-content" /> } `, }) class DropdownComponent {} @Component({ imports: [DropdownComponent, DropdownContentComponent], selector: 'app-menu', template: ` <app-dropdown> <app-dropdown-content> <ng-content /> </app-dropdown-content> </app-dropdown> `, }) class MenuComponent {} @Component({ selector: 'app', imports: [MenuComponent], template: ` <app-menu> Menu Content </app-menu> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor( SimpleComponent, MenuComponent, DropdownComponent, DropdownContentComponent, ); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support cases when view containers are not projected', async () => { @Component({ selector: 'projector-cmp', template: `No content projection slots.`, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> <ng-container *ngIf="true"> <h1>This node is not projected.</h1> <h2>This node is not projected as well.</h2> </ng-container> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support cases when component nodes are not projected', async () => { @Component({ selector: 'projector-cmp', template: `No content projection slots.`, }) class ProjectorCmp {} @Component({ selector: 'nested', template: 'This is a nested component.', }) class NestedComponent {} @Component({ imports: [ProjectorCmp, NestedComponent], selector: 'app', template: ` <projector-cmp> <nested> <h1>This node is not projected.</h1> <h2>This node is not projected as well.</h2> </nested> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support cases when component nodes are not projected in nested components', async () => { @Component({ selector: 'projector-cmp', template: ` <main> <ng-content /> </main> `, }) class ProjectorCmp {} @Component({ selector: 'nested', template: 'No content projection slots.', }) class NestedComponent {} @Component({ imports: [ProjectorCmp, NestedComponent], selector: 'app', template: ` <projector-cmp> <nested> <h1>This node is not projected.</h1> <h2>This node is not projected as well.</h2> </nested> </projector-cmp> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp, NestedComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); it("should project contents with *ngIf's", async () => { @Component({ selector: 'projector-cmp', template: ` <main> <ng-content></ng-content> </main> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp, CommonModule], selector: 'app', template: ` <projector-cmp> <h1 *ngIf="visible">Header with an ngIf condition.</h1> </projector-cmp> `, }) class SimpleComponent { visible = true; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should project contents with *ngFor', async () => { @Component({ selector: 'projector-cmp', template: ` <main> <ng-content></ng-content> </main> `, }) class ProjectorCmp {} @Component({ imports: [ProjectorCmp, CommonModule], selector: 'app', template: ` <projector-cmp> <h1 *ngFor="let item of items">Item {{ item }}</h1> </projector-cmp> `, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support projecting contents outside of a current host element', async () => { @Component({ selector: 'dynamic-cmp', template: `<div #target></div>`, }) class DynamicComponent { @ViewChild('target', {read: ViewContainerRef}) vcRef!: ViewContainerRef; createView(tmplRef: TemplateRef<unknown>) { this.vcRef.createEmbeddedView(tmplRef); } } @Component({ selector: 'projector-cmp', template: ` <ng-template #ref> <ng-content></ng-content> </ng-template> `, }) class ProjectorCmp { @ViewChild('ref', {read: TemplateRef}) tmplRef!: TemplateRef<unknown>; appRef = inject(ApplicationRef); environmentInjector = inject(EnvironmentInjector); doc = inject(DOCUMENT) as Document; isServer = isPlatformServer(inject(PLATFORM_ID)); ngAfterViewInit() { // Create a host DOM node outside of the main app's host node // to emulate a situation where a host node already exists // on a page. let hostElement: Element; if (this.isServer) { hostElement = this.doc.createElement('portal-app'); this.doc.body.insertBefore(hostElement, this.doc.body.firstChild); } else { hostElement = this.doc.querySelector('portal-app')!; } const cmp = createComponent(DynamicComponent, { hostElement, environmentInjector: this.environmentInjector, }); cmp.changeDetectorRef.detectChanges(); cmp.instance.createView(this.tmplRef); this.appRef.attachView(cmp.hostView); } } @Component({ imports: [ProjectorCmp, CommonModule], selector: 'app', template: ` <projector-cmp> <header>Header</header> </projector-cmp> `, }) class SimpleComponent { visible = true; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; const portalRootNode = clientRootNode.ownerDocument.querySelector('portal-app'); verifyAllNodesClaimedForHydration(clientRootNode); verifyAllNodesClaimedForHydration(portalRootNode.firstChild); const clientContents = stripUtilAttributes(portalRootNode.outerHTML, false) + stripUtilAttributes(clientRootNode.outerHTML, false); expect(clientContents).toBe( stripSsrIntegrityMarker(stripUtilAttributes(stripTransferDataScript(ssrContents), false)), 'Client and server contents mismatch', ); }); it('should not render content twice with contentChildren', async () => { // (globalThis as any).ngDevMode = false; @Component({ selector: 'app-shell', imports: [NgTemplateOutlet], template: ` <ng-container [ngTemplateOutlet]="customTemplate"></ng-container> `, }) class ShellCmp { @ContentChild('customTemplate', {static: true}) customTemplate: TemplateRef<any> | null = null; } @Component({ imports: [ShellCmp], selector: 'app', template: ` <app-shell> <ng-template #customTemplate> <p>template</p> </ng-template> </app-shell> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ShellCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, 100_000); it('should handle projected containers inside other containers', async () => { @Component({ selector: 'child-comp', template: '<ng-content />', }) class ChildComp {} @Component({ selector: 'root-comp', template: '<ng-content />', }) class RootComp {} @Component({ selector: 'app', imports: [CommonModule, RootComp, ChildComp], template: ` <root-comp> <ng-container *ngFor="let item of items; last as last"> <child-comp *ngIf="!last">{{ item }}|</child-comp> </ng-container> </root-comp> `, }) class MyApp { items: number[] = [1, 2, 3]; } const html = await ssr(MyApp); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(MyApp, RootComp, ChildComp); const appRef = await prepareEnvironmentAndHydrate(doc, html, MyApp); const compRef = getComponentRef<MyApp>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should throw an error when projecting DOM nodes via ViewContainerRef.createComponent API', async () => { @Component({ selector: 'dynamic', template: ` <ng-content /> <ng-content /> `, }) class DynamicComponent {} @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div #target></div> <main>Hi! This is the main content.</main> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; ngAfterViewInit() { const div = document.createElement('div'); const p = document.createElement('p'); const span = document.createElement('span'); const b = document.createElement('b'); // In this test we create DOM nodes outside of Angular context // (i.e. not using Angular APIs) and try to content-project them. // This is an unsupported pattern and we expect an exception. const compRef = this.vcr.createComponent(DynamicComponent, { projectableNodes: [ [div, p], [span, b], ], }); compRef.changeDetectorRef.detectChanges(); } } try { await ssr(SimpleComponent); } catch (error: unknown) { const errorMessage = (error as Error).toString(); expect(errorMessage).toContain( 'During serialization, Angular detected DOM nodes that ' + 'were created outside of Angular context', ); expect(errorMessage).toContain('<dynamic>…</dynamic> <-- AT THIS LOCATION'); } }); it('should throw an error when projecting DOM nodes via createComponent function call', async () => { @Component({ selector: 'dynamic', template: ` <ng-content /> <ng-content /> `, }) class DynamicComponent {} @Component({ selector: 'app', imports: [NgIf, NgFor], template: ` <div #target></div> <main>Hi! This is the main content.</main> `, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; envInjector = inject(EnvironmentInjector); ngAfterViewInit() { const div = document.createElement('div'); const p = document.createElement('p'); const span = document.createElement('span'); const b = document.createElement('b'); // In this test we create DOM nodes outside of Angular context // (i.e. not using Angular APIs) and try to content-project them. // This is an unsupported pattern and we expect an exception. const compRef = createComponent(DynamicComponent, { environmentInjector: this.envInjector, projectableNodes: [ [div, p], [span, b], ], }); compRef.changeDetectorRef.detectChanges(); } } try { await ssr(SimpleComponent); } catch (error: unknown) { const errorMessage = (error as Error).toString(); expect(errorMessage).toContain( 'During serialization, Angular detected DOM nodes that ' + 'were created outside of Angular context', ); expect(errorMessage).toContain('<dynamic>…</dynamic> <-- AT THIS LOCATION'); } }); it('should not throw when ngSkipHydration is set on a component with projectable nodes created via ViewContainerRef.createComponent', async () => { // Regression test for #67928: NG0503 was thrown during serialization even // when ngSkipHydration was applied to a dynamically created component that // received projectable nodes, because serializeLContainer did not respect // the skip-hydration flag before calling serializeLView. @Component({ selector: 'dynamic', template: `<ng-content />`, host: {'ngSkipHydration': 'true'}, }) class DynamicComponent {} @Component({ selector: 'app', template: `<div #anchor></div>`, }) class SimpleComponent { @ViewChild('anchor', {read: ViewContainerRef}) vcr!: ViewContainerRef; ngAfterViewInit() { const div = document.createElement('div'); this.vcr.createComponent(DynamicComponent, {projectableNodes: [[div]]}); } } // Before the fix this threw NG0503. After the fix it should succeed. const html = await ssr(SimpleComponent); expect(html).toContain('<dynamic'); }); it('should support cases when <ng-content> is used with *ngIf="false"', async () => { @Component({ selector: 'projector-cmp', imports: [NgIf], template: ` Project?: <span>{{ project ? 'yes' : 'no' }}</span> <ng-content *ngIf="project" /> `, changeDetection: ChangeDetectionStrategy.Eager, }) class ProjectorCmp { @Input() project: boolean = false; } @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp [project]="project"> <h1>This node is not projected.</h1> <h2>This node is not projected as well.</h2> </projector-cmp> `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { project = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); let h1 = clientRootNode.querySelector('h1'); let h2 = clientRootNode.querySelector('h2'); let span = clientRootNode.querySelector('span'); expect(h1).not.toBeDefined(); expect(h2).not.toBeDefined(); expect(span.textContent).toBe('no'); // Flip the flag to enable content projection. compRef.instance.project = true; compRef.changeDetectorRef.detectChanges(); h1 = clientRootNode.querySelector('h1'); h2 = clientRootNode.querySelector('h2'); span = clientRootNode.querySelector('span'); expect(h1).toBeDefined(); expect(h2).toBeDefined(); expect(span.textContent).toBe('yes'); }); it('should support cases when <ng-content> is used with *ngIf="true"', async () => { @Component({ selector: 'projector-cmp', imports: [NgIf], template: ` Project?: <span>{{ project ? 'yes' : 'no' }}</span> <ng-content *ngIf="project" /> `, changeDetection: ChangeDetectionStrategy.Eager, }) class ProjectorCmp { @Input() project: boolean = false; } @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp [project]="project"> <h1>This node is projected.</h1> <h2>This node is projected as well.</h2> </projector-cmp> `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { project = true; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); let h1 = clientRootNode.querySelector('h1'); let h2 = clientRootNode.querySelector('h2'); let span = clientRootNode.querySelector('span'); expect(h1).toBeDefined(); expect(h2).toBeDefined(); expect(span.textContent).toBe('yes'); // Flip the flag to disable content projection. compRef.instance.project = false; compRef.changeDetectorRef.detectChanges(); h1 = clientRootNode.querySelector('h1'); h2 = clientRootNode.querySelector('h2'); span = clientRootNode.querySelector('span'); expect(h1).not.toBeDefined(); expect(h2).not.toBeDefined(); expect(span.textContent).toBe('no'); }); it('should support slots with fallback content', async () => { @Component({ selector: 'projector-cmp', template: ` <div> Header slot: <ng-content select="header">Header fallback</ng-content> Main slot: <ng-content select="main"><main>Main fallback</main></ng-content> Footer slot: <ng-content select="footer">Footer fallback {{ expr }}</ng-content> <ng-content>Wildcard fallback</ng-content> </div> `, }) class ProjectorCmp { expr = 123; } @Component({ imports: [ProjectorCmp], selector: 'app', template: `<projector-cmp></projector-cmp>`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; const content = clientRootNode.innerHTML; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(content).toContain('Header slot: Header fallback'); expect(content).toContain('Main slot: <main>Main fallback</main>'); expect(content).toContain('Footer slot: Footer fallback 123'); expect(content).toContain('Wildcard fallback'); }); it('should support mixed slots with and without fallback content', async () => { @Component({ selector: 'projector-cmp', template: ` <div> Header slot: <ng-content select="header">Header fallback</ng-content> Main slot: <ng-content select="main"><main>Main fallback</main></ng-content> Footer slot: <ng-content select="footer">Footer fallback {{ expr }}</ng-content> <ng-content>Wildcard fallback</ng-content> </div> `, }) class ProjectorCmp { expr = 123; } @Component({ imports: [ProjectorCmp], selector: 'app', template: ` <projector-cmp> <header>Header override</header> <footer> <h1>Footer override {{ expr }}</h1> </footer> </projector-cmp> `, }) class SimpleComponent { expr = 321; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent, ProjectorCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; const content = clientRootNode.innerHTML; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(content).toContain('Header slot: <!--container--><header>Header override</header>'); expect(content).toContain('Main slot: <main>Main fallback</main>'); expect(content).toContain( 'Footer slot: <!--container--><footer><h1>Footer override 321</h1></footer>', ); expect(content).toContain('Wildcard fallback'); }); }); describe('error handling', () => { it('should handle text node mismatch', async () => { @Component({ selector: 'app', template: ` <div id="abc">This is an original content</div> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const div = this.doc.querySelector('div'); div!.innerHTML = '<span title="Hi!">This is an extra span causing a problem!</span>'; } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a text node but found <span>', ); expect(message).toContain('#text(This is an original content) <-- AT THIS LOCATION'); expect(message).toContain('<span title="Hi!">…</span> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should not crash when a node can not be found during hydration', async () => { @Component({ selector: 'app', template: ` Some text. <div id="abc">This is an original content</div> `, }) class SimpleComponent { private doc = inject(DOCUMENT); private isServer = isPlatformServer(inject(PLATFORM_ID)); ngAfterViewInit() { if (this.isServer) { const div = this.doc.querySelector('div'); div!.remove(); } } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: withNoopErrorHandler(), }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected <div> but the node was not found', ); expect(message).toContain('<div id="abc">…</div> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle element node mismatch', async () => { @Component({ selector: 'app', template: ` <div id="abc"> <p>This is an original content</p> <b>Bold text</b> <i>Italic text</i> </div> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const b = this.doc.querySelector('b'); const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; b?.parentNode?.replaceChild(span, b); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain('During hydration Angular expected <b> but found <span>'); expect(message).toContain('<b>…</b> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); expect(message).toContain('/guide/hydration#third-party-scripts-with-dom-manipulation'); verifyNodeHasMismatchInfo(doc); }); }); it('should if there are any third-party scripts that manipulate the DOM', async () => { @Component({ selector: 'app', template: `<div>Original content</div>`, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const div = this.doc.querySelector('div'); const ins = this.doc.createElement('ins'); ins.setAttribute('data-ad-client', 'ca-pub-1234'); ins.textContent = 'Ad content'; div?.parentNode?.insertBefore(ins, div); } } const html = await ssr(SimpleComponent); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain('During hydration Angular expected <div> but found <ins>'); expect(message).toContain('/guide/hydration#third-party-scripts-with-dom-manipulation'); }); }); it('should handle <ng-container> node mismatch', async () => { @Component({ selector: 'app', template: ` <b>Bold text</b> <ng-container> <p>This is an original content</p> </ng-container> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const p = this.doc.querySelector('p'); const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; p?.parentNode?.insertBefore(span, p.nextSibling); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found <span>', ); expect(message).toContain('<!-- ng-container --> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); }); }); it( 'should handle <ng-container> node mismatch ' + '(when it is wrapped into a non-container node)', async () => { @Component({ selector: 'app', template: ` <div id="abc" class="wrapper"> <ng-container> <p>This is an original content</p> </ng-container> </div> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const p = this.doc.querySelector('p'); const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; p?.parentNode?.insertBefore(span, p.nextSibling); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found <span>', ); expect(message).toContain('<!-- ng-container --> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); }); }, ); it('should handle <ng-template> node mismatch', async () => { @Component({ selector: 'app', imports: [CommonModule], template: ` <b *ngIf="true">Bold text</b> <i *ngIf="false">Italic text</i> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const b = this.doc.querySelector('b'); const firstCommentNode = b!.nextSibling; const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; firstCommentNode?.parentNode?.insertBefore(span, firstCommentNode.nextSibling); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found <span>', ); expect(message).toContain('<!-- container --> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle node mismatches in nested components', async () => { @Component({ selector: 'nested-cmp', imports: [CommonModule], template: ` <b *ngIf="true">Bold text</b> <i *ngIf="false">Italic text</i> `, }) class NestedComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const b = this.doc.querySelector('b'); const firstCommentNode = b!.nextSibling; const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; firstCommentNode?.parentNode?.insertBefore(span, firstCommentNode.nextSibling); } } @Component({ selector: 'app', imports: [NestedComponent], template: `<nested-cmp />`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found <span>', ); expect(message).toContain('<!-- container --> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); expect(message).toContain('check the "NestedComponent" component'); verifyNodeHasMismatchInfo(doc, 'nested-cmp'); }); }); it('should handle sibling count mismatch', async () => { @Component({ selector: 'app', imports: [CommonModule], template: ` <ng-container *ngIf="true"> <b>Bold text</b> <i>Italic text</i> </ng-container> <main>Main content</main> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { this.doc.querySelector('b')?.remove(); this.doc.querySelector('i')?.remove(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected more sibling nodes to be present', ); expect(message).toContain('<main>…</main> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle ViewContainerRef node mismatch', async () => { @Directive({ selector: 'b', }) class SimpleDir { vcr = inject(ViewContainerRef); } @Component({ selector: 'app', imports: [CommonModule, SimpleDir], template: ` <b>Bold text</b> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const b = this.doc.querySelector('b'); const firstCommentNode = b!.nextSibling; const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; firstCommentNode?.parentNode?.insertBefore(span, firstCommentNode); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found <span>', ); expect(message).toContain('<!-- container --> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle a mismatch for a node that goes after a ViewContainerRef node', async () => { @Directive({ selector: 'b', }) class SimpleDir { vcr = inject(ViewContainerRef); } @Component({ selector: 'app', imports: [CommonModule, SimpleDir], template: ` <b>Bold text</b> <i>Italic text</i> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const b = this.doc.querySelector('b'); const span = this.doc.createElement('span'); span.textContent = 'This is an eeeeevil span causing a problem!'; b?.parentNode?.insertBefore(span, b.nextSibling); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found <span>', ); expect(message).toContain('<!-- container --> <-- AT THIS LOCATION'); expect(message).toContain('<span>…</span> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle a case when a node is not found (removed)', async () => { @Component({ selector: 'projector-cmp', template: '<ng-content />', }) class ProjectorComponent {} @Component({ selector: 'app', imports: [CommonModule, ProjectorComponent], template: ` <projector-cmp> <b>Bold text</b> <i>Italic text</i> </projector-cmp> `, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterContentInit() { this.doc.querySelector('b')?.remove(); this.doc.querySelector('i')?.remove(); } } await ssr(SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During serialization, Angular was unable to find an element in the DOM', ); expect(message).toContain('<b>…</b> <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc, 'projector-cmp'); }); }); it('should handle a case when a node is not found (detached)', async () => { @Component({ selector: 'projector-cmp', template: '<ng-content />', }) class ProjectorComponent {} @Component({ selector: 'app', imports: [CommonModule, ProjectorComponent], template: ` <projector-cmp> <b>Bold text</b> </projector-cmp> `, }) class SimpleComponent { private doc = inject(DOCUMENT); isServer = isPlatformServer(inject(PLATFORM_ID)); constructor() { if (!this.isServer) { this.doc.querySelector('b')?.remove(); } } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withNoopErrorHandler()], }).catch((err: unknown) => { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular was unable to locate a node using the "firstChild" path, ' + 'starting from the <projector-cmp>…</projector-cmp> node', ); verifyNodeHasMismatchInfo(doc, 'projector-cmp'); }); }); it('should handle a case when a node is not found (invalid DOM)', async () => { @Component({ selector: 'app', imports: [CommonModule], template: ` <a> <ng-container *ngTemplateOutlet="titleTemplate"></ng-container> <ng-content *ngIf="true"></ng-content> </a> <ng-template #titleTemplate> <ng-container *ngIf="true"> <a>test</a> </ng-container> </ng-template> `, }) class SimpleComponent {} try { const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); fail('Expected the hydration process to throw.'); } catch (e: unknown) { const message = (e as Error).toString(); expect(message).toContain( 'During hydration, Angular expected an element to be present at this location.', ); expect(message).toContain('<!-- container --> <-- AT THIS LOCATION'); expect(message).toContain('check to see if your template has valid HTML structure'); verifyNodeHasMismatchInfo(doc); } }); it('should log a warning when there was no hydration info in the TransferState', async () => { @Component({ selector: 'app', template: `Hi!`, }) class SimpleComponent {} // Note: SSR *without* hydration logic enabled. const html = await ssr(SimpleComponent, {enableHydration: false}); const ssrContents = getAppContents(html); expect(ssrContents).not.toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withDebugConsole()], }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); verifyHasLog( appRef, 'NG0505: Angular hydration was requested on the client, ' + 'but there was no serialized information present in the server response', ); const clientRootNode = compRef.location.nativeElement; // Make sure that no hydration logic was activated, // effectively re-rendering from scratch happened and // all the content inside the <app> host element was // cleared on the client (as it usually happens in client // rendering mode). verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should not log a warning when there was no hydration info in the TransferState, ' + 'but a client mode marker is present', async () => { @Component({ selector: 'app', template: `Hi!`, }) class SimpleComponent {} const html = `<html><head></head><body ${CLIENT_RENDER_MODE_FLAG}><app></app></body></html>`; resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withDebugConsole()], }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); verifyEmptyConsole(appRef); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.textContent).toContain('Hi!'); }, ); it('should not throw an error when app is destroyed before becoming stable', async () => { // Spy manually, because we may not be able to retrieve the `DebugConsole` // after we destroy the application, but we still want to ensure that // no error is thrown in the console. const errorSpy = spyOn(console, 'error').and.callThrough(); const logs: string[] = []; @Component({ selector: 'app', template: `Hi!`, }) class SimpleComponent { constructor() { const isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); if (isBrowser) { const pendingTasks = inject(PendingTasks); // Given that, in a real-world scenario, some APIs add a pending // task and don't remove it until the app is destroyed. // This could be an HTTP request that contributes to app stability // and does not respond until the app is destroyed. pendingTasks.add(); } } } const html = await ssr(SimpleComponent); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); appRef.isStable.subscribe((isStable) => { logs.push(`isStable=${isStable}`); }); // Destroy the application before it becomes stable, because we added // a task and didn't remove it explicitly. appRef.destroy(); expect(logs).toEqual([ 'isStable=false', // In the end, the application became stable while being destroyed. 'isStable=true', ]); // Wait for a microtask so that `whenStableWithTimeout` resolves. await Promise.resolve(); // Ensure no error has been logged in the console, // such as "injector has already been destroyed." expect(errorSpy).not.toHaveBeenCalled(); }); }); describe('@if', () => { it('should work with `if`s that have different value on the client and on the server', async () => { @Component({ selector: 'app', imports: [NgIf], template: ` <ng-container *ngIf="isServer; else elseBlock"> <b>This is NgIf SERVER-ONLY content</b> </ng-container> <ng-template #elseBlock> <i>This is NgIf CLIENT-ONLY content</i> </ng-template> @if (isServer) { <b>This is new if SERVER-ONLY content</b> } @else { <i id="client-only">This is new if CLIENT-ONLY content</i> } @if (alwaysTrue) { <p>CLIENT and SERVER content</p> } `, }) class SimpleComponent { alwaysTrue = true; // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); pendingTasks = inject(PendingTasks); ngOnInit() { const remove = this.pendingTasks.add(); setTimeout(() => void remove(), 100); } } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain( '<i id="client-only">This is new if CLIENT-ONLY content</i>', ); expect(ssrContents).toContain('<b>This is new if SERVER-ONLY content</b>'); expect(ssrContents).not.toContain('<i>This is NgIf CLIENT-ONLY content</i>'); expect(ssrContents).toContain('<b>This is NgIf SERVER-ONLY content</b>'); // Content that should be rendered on both client and server should also be present. expect(ssrContents).toContain('<p>CLIENT and SERVER content</p>'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.outerHTML).not.toContain('<b>This is NgIf SERVER-ONLY content</b>'); expect(clientRootNode.outerHTML).not.toContain('<b>This is new if SERVER-ONLY content</b>'); await appRef.whenStable(); // post-hydration cleanup happens here const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain( '<i id="client-only">This is new if CLIENT-ONLY content</i>', ); expect(clientContents).not.toContain('<b>This is new if SERVER-ONLY content</b>'); // Content that should be rendered on both client and server should still be present. expect(clientContents).toContain('<p>CLIENT and SERVER content</p>'); const clientOnlyNode1 = clientRootNode.querySelector('i'); const clientOnlyNode2 = clientRootNode.querySelector('#client-only'); verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode1, clientOnlyNode2]); }); it('should support nested `if`s', async () => { @Component({ selector: 'app', template: ` This is a non-empty block: @if (true) { @if (true) { <h1> @if (true) { <span>Hello world!</span> } </h1> } } <div>Post-container element</div> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate `else` blocks', async () => { @Component({ selector: 'app', template: ` @if (conditionA) { if block } @else { else block } `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { conditionA = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); expect(ssrContents).toContain(`else block`); expect(ssrContents).not.toContain(`if block`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); // Verify that we still have expected content rendered. expect(clientRootNode.innerHTML).toContain(`else block`); expect(clientRootNode.innerHTML).not.toContain(`if block`); // Verify that switching `if` condition results // in an update to the DOM which was previously hydrated. compRef.instance.conditionA = true; compRef.changeDetectorRef.detectChanges(); expect(clientRootNode.innerHTML).not.toContain(`else block`); expect(clientRootNode.innerHTML).toContain(`if block`); }); }); describe('@switch', () => { it('should work with `switch`es that have different value on the client and on the server', async () => { @Component({ selector: 'app', imports: [NgSwitch, NgSwitchCase], template: ` <ng-container [ngSwitch]="isServer"> <b *ngSwitchCase="true">This is NgSwitch SERVER-ONLY content</b> <i *ngSwitchCase="false" id="old">This is NgSwitch CLIENT-ONLY content</i> </ng-container> @switch (isServer) { @case (true) { <b>This is a SERVER-ONLY content</b> } @case (false) { <i id="new">This is a CLIENT-ONLY content</i> } } `, }) class SimpleComponent { // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. isServer = isPlatformServer(inject(PLATFORM_ID)); pendingTasks = inject(PendingTasks); ngOnInit() { const remove = this.pendingTasks.add(); setTimeout(() => { remove(); }, 100); } } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // In the SSR output we expect to see SERVER content, but not CLIENT. expect(ssrContents).not.toContain('<i id="new">This is a CLIENT-ONLY content</i>'); expect(ssrContents).not.toContain('<i id="old">This is NgSwitch CLIENT-ONLY content</i>'); expect(ssrContents).toContain('<b>This is a SERVER-ONLY content</b>'); expect(ssrContents).toContain('<b>This is NgSwitch SERVER-ONLY content</b>'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; // NgSwitch had slower cleanup than NgIf expect(clientRootNode.outerHTML).toContain('<b>This is NgSwitch SERVER-ONLY content</b>'); expect(clientRootNode.outerHTML).not.toContain('<b>This is a SERVER-ONLY content</b>'); expect(clientRootNode.outerHTML).toContain('<i id="new">This is a CLIENT-ONLY content</i>'); expect(clientRootNode.outerHTML).toContain( '<i id="old">This is NgSwitch CLIENT-ONLY content</i>', ); await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect to see CLIENT content, but not SERVER. expect(clientContents).toContain('<i id="new">This is a CLIENT-ONLY content</i>'); expect(clientContents).toContain('<i id="old">This is NgSwitch CLIENT-ONLY content</i>'); expect(clientContents).not.toContain('<b>This is NgSwitch SERVER-ONLY content</b>'); expect(clientContents).not.toContain('<b>This is a SERVER-ONLY content</b>'); const clientOnlyNode1 = clientRootNode.querySelector('#old'); const clientOnlyNode2 = clientRootNode.querySelector('#new'); verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode1, clientOnlyNode2]); }); it('should cleanup rendered case if none of the cases match on the client', async () => { @Component({ selector: 'app', template: ` @switch (label) { @case ('A') { This is A } @case ('B') { This is B } } `, }) class SimpleComponent { // This flag is intentionally different between the client // and the server: we use it to test the logic to cleanup // dehydrated views. label = isPlatformServer(inject(PLATFORM_ID)) ? 'A' : 'Not A'; } const html = await ssr(SimpleComponent); let ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); expect(ssrContents).toContain('This is A'); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); const clientContents = stripExcessiveSpaces( stripUtilAttributes(clientRootNode.outerHTML, false), ); // After the cleanup, we expect that the contents is removed and none // of the cases are rendered, since they don't match the condition. expect(clientContents).not.toContain('This is A'); expect(clientContents).not.toContain('This is B'); verifyAllNodesClaimedForHydration(clientRootNode); }); }); describe('@for', () => { it('should hydrate for loop content', async () => { @Component({ selector: 'app', template: ` @for (item of items; track item) { <div> <h1>Item #{{ item }}</h1> </div> } `, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Check whether serialized hydration info has a multiplier // (which avoids repeated views serialization). const hydrationInfo = getHydrationInfoFromTransferState(ssrContents); expect(hydrationInfo).toContain('"x":3'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate @empty block content', async () => { @Component({ selector: 'app', template: ` @for (item of items; track item) { <p>Item #{{ item }}</p> } @empty { <div>This is an "empty" block</div> } `, }) class SimpleComponent { items = []; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should handle a case when @empty block is rendered ' + 'on the server and main content on the client', async () => { @Component({ selector: 'app', template: ` @for (item of items; track item) { <p>Item #{{ item }}</p> } @empty { <div>This is an "empty" block</div> } `, }) class SimpleComponent { items = isPlatformServer(inject(PLATFORM_ID)) ? [] : [1, 2, 3]; ngOnInit() { setTimeout(() => {}, 100); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); // Expect only the `@empty` block to be rendered on the server. expect(ssrContents).not.toContain('Item #1'); expect(ssrContents).not.toContain('Item #2'); expect(ssrContents).not.toContain('Item #3'); expect(ssrContents).toContain('This is an "empty" block'); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.innerHTML).toContain('Item #1'); expect(clientRootNode.innerHTML).toContain('Item #2'); expect(clientRootNode.innerHTML).toContain('Item #3'); expect(clientRootNode.innerHTML).not.toContain('This is an "empty" block'); await appRef.whenStable(); // After hydration and post-hydration cleanup, // expect items to be present, but `@empty` block to be removed. expect(clientRootNode.innerHTML).toContain('Item #1'); expect(clientRootNode.innerHTML).toContain('Item #2'); expect(clientRootNode.innerHTML).toContain('Item #3'); expect(clientRootNode.innerHTML).not.toContain('This is an "empty" block'); const clientRenderedItems = compRef.location.nativeElement.querySelectorAll('p'); verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems)); }, ); it( 'should handle a case when @empty block is rendered ' + 'on the client and main content on the server', async () => { @Component({ selector: 'app', template: ` @for (item of items; track item) { <p>Item #{{ item }}</p> } @empty { <div>This is an "empty" block</div> } `, }) class SimpleComponent { items = isPlatformServer(inject(PLATFORM_ID)) ? [1, 2, 3] : []; ngOnInit() { setTimeout(() => {}, 100); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Expect items to be rendered on the server. expect(ssrContents).toContain('Item #1'); expect(ssrContents).toContain('Item #2'); expect(ssrContents).toContain('Item #3'); expect(ssrContents).not.toContain('This is an "empty" block'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.innerHTML).not.toContain('Item #1'); expect(clientRootNode.innerHTML).not.toContain('Item #2'); expect(clientRootNode.innerHTML).not.toContain('Item #3'); expect(clientRootNode.innerHTML).toContain('This is an "empty" block'); await appRef.whenStable(); // After hydration and post-hydration cleanup, // expect an `@empty` block to be present and items to be removed. expect(clientRootNode.innerHTML).not.toContain('Item #1'); expect(clientRootNode.innerHTML).not.toContain('Item #2'); expect(clientRootNode.innerHTML).not.toContain('Item #3'); expect(clientRootNode.innerHTML).toContain('This is an "empty" block'); const clientRenderedItems = compRef.location.nativeElement.querySelectorAll('div'); verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems)); }, ); it('should handle different number of items rendered on the client and on the server', async () => { @Component({ selector: 'app', template: ` @for (item of items; track item) { <p id="{{ item }}">Item #{{ item }}</p> } `, }) class SimpleComponent { // Item '3' is the same, the rest of the items are different. items = isPlatformServer(inject(PLATFORM_ID)) ? [3, 2, 1] : [3, 4, 5]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); expect(ssrContents).toContain('Item #1'); expect(ssrContents).toContain('Item #2'); expect(ssrContents).toContain('Item #3'); expect(ssrContents).not.toContain('Item #4'); expect(ssrContents).not.toContain('Item #5'); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; // After hydration and post-hydration cleanup, // expect items to be present, but `@empty` block to be removed. expect(clientRootNode.innerHTML).not.toContain('Item #1'); expect(clientRootNode.innerHTML).not.toContain('Item #2'); expect(clientRootNode.innerHTML).toContain('Item #3'); expect(clientRootNode.innerHTML).toContain('Item #4'); expect(clientRootNode.innerHTML).toContain('Item #5'); // Note: we exclude item '3', since it's the same (and at the same location) // on the server and on the client, so it was hydrated. const clientRenderedItems = [4, 5].map((id) => compRef.location.nativeElement.querySelector(`[id=${id}]`), ); verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems)); }); it('should handle a reconciliation with swaps', async () => { @Component({ selector: 'app', template: ` @for (item of items; track item) { <div>{{ item }}</div> } `, }) class SimpleComponent { items = ['a', 'b', 'c']; swap() { // Reshuffling of the array will result in // "swap" operations in repeater. this.items = ['b', 'c', 'a']; } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); resetTViewsFor(SimpleComponent); expect(ssrContents).toContain('a'); expect(ssrContents).toContain('b'); expect(ssrContents).toContain('c'); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const root: HTMLElement = compRef.location.nativeElement; const divs = root.querySelectorAll('div'); expect(divs.length).toBe(3); compRef.instance.swap(); compRef.changeDetectorRef.detectChanges(); const divsAfterSwap = root.querySelectorAll('div'); expect(divsAfterSwap.length).toBe(3); }); }); describe('@let', () => { it('should handle a let declaration', async () => { @Component({ selector: 'app', template: ` @let greeting = name + '!!!'; Hello, {{ greeting }} `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { name = 'Frodo'; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('Hello, Frodo!!!'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('Hello, Frodo!!!'); compRef.instance.name = 'Bilbo'; compRef.changeDetectorRef.detectChanges(); expect(clientRootNode.textContent).toContain('Hello, Bilbo!!!'); }); it('should handle multiple let declarations that depend on each other', async () => { @Component({ selector: 'app', template: ` @let plusOne = value + 1; @let plusTwo = plusOne + 1; @let result = plusTwo + 1; Result: {{ result }} `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { value = 1; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('Result: 4'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('Result: 4'); compRef.instance.value = 2; compRef.changeDetectorRef.detectChanges(); expect(clientRootNode.textContent).toContain('Result: 5'); }); it('should handle a let declaration using a pipe that injects ChangeDetectorRef', async () => { @Pipe({ name: 'double', }) class DoublePipe implements PipeTransform { changeDetectorRef = inject(ChangeDetectorRef); transform(value: number) { return value * 2; } } @Component({ selector: 'app', imports: [DoublePipe], template: ` @let result = value | double; Result: {{ result }} `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { value = 1; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('Result: 2'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('Result: 2'); compRef.instance.value = 2; compRef.changeDetectorRef.detectChanges(); expect(clientRootNode.textContent).toContain('Result: 4'); }); it('should handle let declarations referenced through multiple levels of views', async () => { @Component({ selector: 'app', template: ` @if (true) { @if (true) { @let three = two + 1; The result is {{ three }} } @let two = one + 1; } @let one = value + 1; `, changeDetection: ChangeDetectionStrategy.Eager, }) class SimpleComponent { value = 0; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('The result is 3'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('The result is 3'); compRef.instance.value = 2; compRef.changeDetectorRef.detectChanges(); expect(clientRootNode.textContent).toContain('The result is 5'); }); it('should handle non-projected let declarations', async () => { @Component({ selector: 'inner', template: ` <ng-content select="header">Fallback header</ng-content> <ng-content>Fallback content</ng-content> <ng-content select="footer">Fallback footer</ng-content> `, }) class InnerComponent {} @Component({ selector: 'app', template: ` <inner> @let one = 1; <footer>|Footer value {{ one }}</footer> @let two = one + 1; <header>Header value {{ two }}|</header> </inner> `, imports: [InnerComponent], }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); const expectedContent = '<!--container--><header>Header value 2|</header>' + 'Fallback content<!--container--><!--container-->' + '<footer>|Footer value 1</footer>'; expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain(`<inner ngh="0">${expectedContent}</inner>`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.innerHTML).toContain(`<inner>${expectedContent}</inner>`); }); it('should handle let declaration before and directly inside of an embedded view', async () => { @Component({ selector: 'app', template: ` @let before = 'before'; @if (true) { @let inside = 'inside'; {{ before }}|{{ inside }} } `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('before|inside'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('before|inside'); }); it('should handle let declaration before, directly inside of and after an embedded view', async () => { @Component({ selector: 'app', template: ` @let before = 'before'; @if (true) { @let inside = 'inside'; {{ inside }} } @let after = 'after'; {{ before }}|{{ after }} `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('inside <!--container--> before|after'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('inside before|after'); }); it('should handle let declaration with array inside of an embedded view', async () => { @Component({ selector: 'app', template: ` @let foo = ['foo']; @if (true) { {{ foo }} } `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain('foo'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('foo'); }); it('should handle let declaration inside a projected control flow node', async () => { @Component({ selector: 'test', template: 'Main: <ng-content/> Slot: <ng-content slot="foo"/>', }) class TestComponent {} @Component({ selector: 'app', imports: [TestComponent], template: ` <test> @let a = 1; @let b = a + 1; <span foo>{{ b }}</span> </test> `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('<app ngh'); expect(ssrContents).toContain( 'Main: <!--ngtns--> Slot: <span foo="">2</span></test></app>', ); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('Main: Slot: 2'); }); }); describe('Router', () => { it('should wait for lazy routes before triggering post-hydration cleanup', async () => { const ngZone = TestBed.inject(NgZone); @Component({ selector: 'lazy', template: `LazyCmp content`, }) class LazyCmp {} const routes: Routes = [ { path: '', loadComponent: () => { return ngZone.runOutsideAngular(() => { return new Promise((resolve) => { setTimeout(() => resolve(LazyCmp), 100); }); }); }, }, ]; @Component({ selector: 'app', imports: [RouterOutlet], template: ` Works! <router-outlet /> `, }) class SimpleComponent {} const envProviders = [ {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter(routes), ] as unknown as Provider[]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Expect serialization to happen once a lazy-loaded route completes loading // and a lazy component is rendered. expect(ssrContents).toContain(`<lazy ${NGH_ATTR_NAME}="0">LazyCmp content</lazy>`); resetTViewsFor(SimpleComponent, LazyCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should wait for lazy routes before triggering post-hydration cleanup in zoneless mode', async () => { const ngZone = TestBed.inject(NgZone); @Component({ selector: 'lazy', template: `LazyCmp content`, }) class LazyCmp {} const routes: Routes = [ { path: '', loadComponent: () => { return ngZone.runOutsideAngular(() => { return new Promise((resolve) => { setTimeout(() => resolve(LazyCmp), 100); }); }); }, }, ]; @Component({ selector: 'app', imports: [RouterOutlet], template: ` Works! <router-outlet /> `, }) class SimpleComponent {} const envProviders = [ {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter(routes), ] as unknown as Provider[]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); // Expect serialization to happen once a lazy-loaded route completes loading // and a lazy component is rendered. expect(ssrContents).toContain(`<lazy ${NGH_ATTR_NAME}="0">LazyCmp content</lazy>`); resetTViewsFor(SimpleComponent, LazyCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; await appRef.whenStable(); verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should cleanup dehydrated views in routed components that use ViewContainerRef', async () => { @Component({ selector: 'cmp-a', template: ` @if (isServer) { <p>Server view</p> } @else { <p>Client view</p> } `, }) class CmpA { isServer = isPlatformServer(inject(PLATFORM_ID)); viewContainerRef = inject(ViewContainerRef); } const routes: Routes = [ { path: '', component: CmpA, }, ]; @Component({ selector: 'app', imports: [RouterOutlet], template: ` <router-outlet /> `, }) class SimpleComponent {} const envProviders = [ {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter(routes), ] as unknown as Provider[]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`); expect(ssrContents).toContain('Server view'); expect(ssrContents).not.toContain('Client view'); resetTViewsFor(SimpleComponent, CmpA); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); const compRef = getComponentRef<SimpleComponent>(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; // <p> tag is used in a view that is different on a server and // on a client, so it gets re-created (not hydrated) on a client const p = clientRootNode.querySelector('p'); verifyAllNodesClaimedForHydration(clientRootNode, [p]); expect(clientRootNode.innerHTML).not.toContain('Server view'); expect(clientRootNode.innerHTML).toContain('Client view'); }); }); }); });