/** * @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, ChangeDetectorRef, Component, ContentChildren, createComponent, destroyPlatform, Directive, ɵCLIENT_RENDER_MODE_FLAG as CLIENT_RENDER_MODE_FLAG, ElementRef, EnvironmentInjector, ErrorHandler, inject, Input, NgZone, PendingTasks, Pipe, PipeTransform, PLATFORM_ID, provideZonelessChangeDetection, Provider, QueryList, TemplateRef, ViewChild, ViewContainerRef, ViewEncapsulation, ɵNoopNgZone as NoopNgZone, ContentChild, provideZoneChangeDetection, } 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', () => { beforeEach(() => { resetNgDevModeCounters(); }); afterEach(() => { destroyPlatform(); }); 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({ standalone: true, selector: 'nested', template: 'This is a nested component.', }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(` { @Component({ standalone: true, selector: 'nested', template: 'This is a nested component.', }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(` { @Component({ standalone: true, selector: 'app', template: ` Hi! `, }) class SimpleComponent { @ViewChild('tmpl', {read: TemplateRef}) tmplRef!: TemplateRef; 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(` { it('should wipe out existing host element content when server side rendering', async () => { @Component({ standalone: true, selector: 'app', template: `
Some content
`, }) class SimpleComponent {} const extraChildNodes = ' Some text! and a tag'; const doc = `${extraChildNodes}`; 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('
Some content
'); }); }); describe('hydration', () => { it('should remove ngh attributes after hydration on the client', async () => { @Component({ standalone: true, selector: 'app', template: 'Hi!', }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'app', template: ` This is hydrated content. `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate root components with empty templates', async () => { @Component({ standalone: true, selector: 'app', template: '', }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate child components with empty templates', async () => { @Component({ standalone: true, selector: 'child', template: '', }) class ChildComponent {} @Component({ standalone: true, imports: [ChildComponent], selector: 'app', template: '', }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support a single text interpolation', async () => { @Component({ standalone: true, selector: 'app', template: ` {{ text }} `, }) class SimpleComponent { text = 'text'; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support text and HTML elements', async () => { @Component({ standalone: true, selector: 'app', template: `
Header
This is hydrated content in the main element.
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This is the content of a nested component
`, }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'app', template: `
Header
This is hydrated content in the main element.
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'app', template: `
Some content
`, }) class SimpleComponent {} const extraChildNodes = ' Some text! and a tag'; const docContent = `${extraChildNodes}`; const html = await ssr(SimpleComponent, {doc: docContent}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('ng-container', () => { it('should support empty containers', async () => { @Component({ standalone: true, selector: 'app', template: ` This is an empty container: `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support non-empty containers', async () => { @Component({ standalone: true, selector: 'app', template: ` This is a non-empty container:

Hello world!

Post-container element
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support nested containers', async () => { @Component({ standalone: true, selector: 'app', template: ` This is a non-empty container:

Hello world!

Post-container element
Tags between containers
More tags between containers

Hello world!

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support element containers with *ngIf', async () => { @Component({ selector: 'cmp', standalone: true, template: 'Hi!', }) class Cmp {} @Component({ standalone: true, selector: 'app', imports: [NgIf], template: `
`, }) 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(`(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({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a non-empty container:

Hello world!

Post-container element
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is an empty container:
Post-container element
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with *ngIf on element nodes', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf], template: `

Hello world!

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with empty containers on element nodes', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf], template: `

Hello world!

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with *ngIf on component host nodes', async () => { @Component({ standalone: true, selector: 'nested-cmp', imports: [NgIf], template: `

Hello World!

`, }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NgIf, NestedComponent], template: ` This is a component:
Post-container element
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support nested *ngIfs', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a non-empty container:

Hello world!

Post-container element
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('*ngFor', () => { it('should support *ngFor on nodes', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `

Item #{{ item }}

Post-container element
`, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support *ngFor on element nodes', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `

Item #{{ item }}

Post-container element
`, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support *ngFor on host component nodes', async () => { @Component({ standalone: true, selector: 'nested-cmp', imports: [NgIf], template: `

Hello World!

`, }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor, NestedComponent], template: `
Post-container element
`, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support compact serialization for *ngFor', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `
Number {{ number }} is in [0, 5) range. is in [5, 8) range. is in [8, 10) range.
`, }) class SimpleComponent { numbers = [...Array(10).keys()]; // [0..9] } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('*ngComponentOutlet', () => { it('should support hydration on nodes', async () => { @Component({ standalone: true, selector: 'nested-cmp', imports: [NgIf], template: `

Hello World!

`, }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NgComponentOutlet], template: ` `, }) 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(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support hydration on element nodes', async () => { @Component({ standalone: true, selector: 'nested-cmp', imports: [NgIf], template: `

Hello World!

`, }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NgComponentOutlet], template: `
`, }) 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(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support hydration for nested components', async () => { @Component({ standalone: true, selector: 'nested-cmp', imports: [NgIf], template: `

Hello World!

`, }) class NestedComponent {} @Component({ standalone: true, selector: 'other-nested-cmp', imports: [NgComponentOutlet], template: ` `, }) class OtherNestedComponent { // This field is necessary to expose // the `NestedComponent` to the template. NestedComponent = NestedComponent; } @Component({ standalone: true, selector: 'app', imports: [NgComponentOutlet], template: ` `, }) 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(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('*ngTemplateOutlet', () => { it('should work with ', async () => { @Component({ standalone: true, selector: 'app', imports: [NgTemplateOutlet], template: ` This is a content of the template! `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with element nodes', async () => { @Component({ standalone: true, selector: 'app', imports: [NgTemplateOutlet], template: ` This is a content of the template!
Some extra content
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); describe('ViewContainerRef', () => { it('should work with ViewContainerRef.createComponent', async () => { @Component({ standalone: true, selector: 'dynamic', template: ` This is a content of a dynamic component. `, }) class DynamicComponent {} @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `
Hi! This is the main content.
`, }) 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('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should work with ViewContainerRef.createEmbeddedView', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `

This is a content of an ng-template.

`, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef; ngAfterViewInit() { const viewRef = this.vcr.createEmbeddedView(this.tmpl); viewRef.detectChanges(); } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, imports: [CommonModule], selector: 'dynamic', template: ` This is a content of a dynamic component. `, }) class DynamicComponent {} @Component({ standalone: true, selector: 'app', template: `
Hi! This is the main content.
`, }) 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('(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({ standalone: true, selector: 'app', template: `

Content of embedded view

Hi! This is the main content.
`, }) class SimpleComponent { @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef; 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('(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', async () => { @Component({ standalone: true, imports: [CommonModule], selector: 'nested-dynamic-a', template: `

NestedDynamicComponentA

`, }) class NestedDynamicComponentA {} @Component({ standalone: true, imports: [CommonModule], selector: 'nested-dynamic-b', template: `

NestedDynamicComponentB

`, }) class NestedDynamicComponentB {} @Component({ standalone: true, imports: [CommonModule], selector: 'dynamic', template: ` This is a content of a dynamic component. `, }) class DynamicComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(NestedDynamicComponentB); compRef.changeDetectorRef.detectChanges(); } } @Component({ standalone: true, selector: 'app', template: `
Hi! This is the main content.
`, }) 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.: // ``` // // // //
// Host element for DynamicComponent // // // ``` // 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 = '' + '' + '
' + ''; const html = await ssr(SimpleComponent, {doc: indexHtml}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'another-dynamic', template: `This is a content of another dynamic component.`, }) class AnotherDynamicComponent { vcr = inject(ViewContainerRef); } @Component({ standalone: true, selector: 'dynamic', template: `This is a content of a dynamic component.`, }) class DynamicComponent { vcr = inject(ViewContainerRef); ngAfterViewInit() { const compRef = this.vcr.createComponent(AnotherDynamicComponent); compRef.changeDetectorRef.detectChanges(); } } @Component({ standalone: true, selector: 'app', template: `
Hi! This is the main content.
`, }) 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('(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({ standalone: true, selector: 'dynamic', template: `

Content of an embedded view

Hi! This is the dynamic component content.
`, }) class DynamicComponent { @ViewChild('tmpl', {read: TemplateRef}) tmpl!: TemplateRef; vcr = inject(ViewContainerRef); ngAfterViewInit() { const viewRef = this.vcr.createEmbeddedView(this.tmpl); viewRef.detectChanges(); } } @Component({ standalone: true, selector: 'app', template: `
Hi! This is the main content.
`, }) 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('(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({ standalone: true, selector: 'app', template: `

Content of H1

Content of H2

Content of H3

Pre-container content

Post-container content
`, }) class SimpleComponent { @ViewChild('target', {read: ViewContainerRef}) vcr!: ViewContainerRef; @ViewChild('tmplH1', {read: TemplateRef}) tmplH1!: TemplateRef; @ViewChild('tmplH2', {read: TemplateRef}) tmplH2!: TemplateRef; @ViewChild('tmplH3', {read: TemplateRef}) tmplH3!: TemplateRef; 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('(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({ standalone: true, selector: 'app', template: `Hello World!`, }) class SimpleComponent { private vcRef = inject(ViewContainerRef); } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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>/, ''); verifyClientAndSSRContentsMatch(_ssrContents, clientRootNode); }); }); describe('', () => { it('should support unused s', async () => { @Component({ standalone: true, selector: 'app', template: ` Some content
Tag in between
Some content

Tag in between

Some content Nested template content. `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, imports: [CommonModule], selector: 'insertion-component', template: ` `, }) class InsertionComponent { @Input() template!: TemplateRef; } @Component({ standalone: true, selector: 'app', imports: [InsertionComponent, CommonModule], template: ` This is a transplanted view!
With more nested views!
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: '
Hi!
', }) 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({ standalone: true, selector: 'app', template: `
Hi!
`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { @Component({ standalone: true, imports: [NgIf], selector: 'app', template: `
Hi!
`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('s', async () => { @Component({ standalone: true, selector: 'app', template: ` Hi! `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('s)', async () => { @Component({ standalone: true, imports: [CommonModule], selector: 'app', template: ` Hi! `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { loadTranslations({ [computeMsgId('Some {$START_TAG_STRONG}strong{$CLOSE_TAG_STRONG} content')]: 'Some normal content', }); @Component({ standalone: true, selector: 'app', template: `
Some strong content
`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app-content', template: ``, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: `
one
two
`, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toBe('two
one
'); }); it('should work when i18n content is not projected', async () => { @Component({ standalone: true, selector: 'app-content', template: ` @if (false) { } Content outside of 'if'. `, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: `
Hello!
Hello again!
`, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app-content', template: `Start Middle End`, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: ` Span Middle Start Middle End
Div
`, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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
Middle Span End'); }); it('should support disjoint nodes', async () => { @Component({ standalone: true, selector: 'app-content', template: `Start Middle End`, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: ` Inner Start Span { count, plural, other { Hello World! }} Inner End `, imports: [ContentComponent], }) class SimpleComponent { count = 0; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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 World! Inner End Middle Span End', ); }); it('should support nested content projection', async () => { @Component({ standalone: true, selector: 'app-content-inner', template: `Start Middle End`, }) class InnerContentComponent {} @Component({ standalone: true, selector: 'app-content-outer', template: ``, imports: [InnerContentComponent], }) class OuterContentComponent {} @Component({ standalone: true, selector: 'app', template: ` Outer Start Span { count, plural, other { Hello World! }} Outer End `, imports: [OuterContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content-outer'); expect(content.innerHTML).toBe( 'Start Outer Start Span Hello World! Outer End Middle End', ); }); it('should support hosting projected content', async () => { @Component({ standalone: true, selector: 'app-content', template: `Start End`, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: `
Middle
`, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toBe('Start Middle End'); }); it('should support projecting multiple elements', async () => { @Component({ standalone: true, selector: 'app-content', template: ``, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: ` Start Middle End `, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toMatch(/ Start Middle<\/span> End /); }); it('should support disconnecting i18n nodes during projection', async () => { @Component({ standalone: true, selector: 'app-content', template: `Start End`, }) class ContentComponent {} @Component({ standalone: true, selector: 'app', template: ` Middle Start Middle Middle End `, imports: [ContentComponent], }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const content = clientRootNode.querySelector('app-content'); expect(content.innerHTML).toBe('Start Middle End'); }); it('should support using translated views as view container anchors', async () => { @Component({ standalone: true, selector: 'dynamic-cmp', template: `DynamicComponent content`, }) class DynamicComponent {} @Component({ standalone: true, selector: 'app', template: `
one
two
`, }) 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('(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( '
one
DynamicComponent contenttwo', ); }); 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({ standalone: true, selector: 'app', template: `
one
two
`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const div = clientRootNode.querySelector('div'); expect(div.innerHTML).toBe('dos
uno
'); }); it('should support translations that include additional elements', async () => { loadTranslations({ [computeMsgId('{VAR_PLURAL, plural, other {normal}}')]: '{VAR_PLURAL, plural, other {strong}}', }); @Component({ standalone: true, selector: 'app', template: `
Some {case, plural, other {normal}} content
`, }) class SimpleComponent { case = 0; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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> content/); }); it('should support translations that remove elements', async () => { loadTranslations({ [computeMsgId('Hello {$START_TAG_STRONG}World{$CLOSE_TAG_STRONG}!')]: 'Bonjour!', }); @Component({ standalone: true, selector: 'app', template: `
Hello World!
`, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: `
{isServer, select, true { This is a SERVER-ONLY content } false { This is a CLIENT-ONLY content }}
`, }) class SimpleComponent { isServer = isPlatformServer(inject(PLATFORM_ID)) + ''; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); let ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: `
{{firstCase}} {firstCase, plural, =1 {item} other {items}}, {{secondCase}} {secondCase, plural, =1 {item} other {items}}
`, }) class SimpleComponent { firstCase = 0; secondCase = 1; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'simple-component', template: `
{firstCase, select, 1 {one-{secondCase, select, 1 {one} 2 {two}}} 2 {two-{secondCase, select, 1 {one} 2 {two}}}}
`, }) class SimpleComponent { @Input() firstCase!: number; @Input() secondCase!: number; } @Component({ standalone: true, imports: [SimpleComponent], selector: 'app', template: ` `, }) class AppComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(AppComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: ` Container #1 Container #2 `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML); expect(clientContents).toBe( ' Container #1 Container #2 ', ); }); it('should hydrate when using the *ngFor directive', async () => { @Component({ standalone: true, imports: [NgFor], selector: 'app', template: `
  1. {{ item }}
`, }) class SimpleComponent { items = [1, 2, 3]; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML); expect(clientContents).toBe('
  1. 1
  2. 2
  3. 3
'); }); it('should hydrate when using @for control flow', async () => { @Component({ standalone: true, selector: 'app', template: `
    @for (item of items; track $index) {
  1. {{ item }}
  2. }
`, }) class SimpleComponent { items = [1, 2, 3]; } const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); const clientContents = stripExcessiveSpaces(clientRootNode.innerHTML); expect(clientContents).toBe('
  1. 1
  2. 2
  3. 3
'); }); 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({ standalone: true, selector: 'cmp-a', template: ``, }) class CmpA {} @Component({ standalone: true, selector: 'app', imports: [CmpA], template: ` Some strong content `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'cmp-a', template: ``, }) class CmpA {} @Component({ standalone: true, selector: 'app', imports: [CmpA], template: ` Some strong content `, }) class SimpleComponent {} const hydrationFeatures = () => [withI18nSupport()]; const html = await ssr(SimpleComponent, {hydrationFeatures}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: `
Hi!
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(''); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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({ standalone: true, selector: 'app', host: {ngSkipHydration: 'true'}, template: `
Hi!
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(''); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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({ standalone: true, imports: [NgIf], selector: 'app', template: `
Hi!
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(''); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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 s', async () => { @Component({ standalone: true, selector: 'app', template: ` Hi! `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(''); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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 s)', async () => { @Component({ standalone: true, imports: [CommonModule], selector: 'app', template: ` Hi! `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(''); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyNoNodesWereClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should *not* throw when i18n attributes are used', async () => { @Component({ standalone: true, selector: 'app', template: `
Hi!
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'nested', template: `
Hi!
`, }) class NestedComponent {} @Component({ standalone: true, imports: [NestedComponent], selector: 'app', template: ` Nested component with i18n inside: `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should exclude components with i18n from hydration automatically', async () => { @Component({ standalone: true, selector: 'nested', template: `
Hi!
`, }) class NestedComponent {} @Component({ standalone: true, imports: [NestedComponent], selector: 'app', template: ` Nested component with i18n inside (the content of this component would be excluded from hydration): `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp], template: ` Visible: {{ isVisible }}. @defer (when isVisible) { } @loading { Loading... } @placeholder { Placeholder! } @error { Failed to load dependencies :( } `, }) class SimpleComponent { isVisible = false; ngOnInit() { setTimeout(() => { // This changes the triggering condition of the defer block, // but it should be ignored and the placeholder content should be visible. this.isVisible = true; }); } } const envProviders = [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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('Hi!'); }); 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', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp], template: ` @defer (on timer(123ms)) { } `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { @Component({ selector: 'my-lazy-cmp', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'my-placeholder-cmp', standalone: true, imports: [NgIf], template: '
Hi!
', }) class MyPlaceholderCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp], template: ` Visible: {{ isVisible }}. @defer (when isVisible) { } @loading { Loading... } @placeholder { Placeholder! } @error { Failed to load dependencies :( } `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('
Hi!
'); resetTViewsFor(SimpleComponent, MyPlaceholderCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ selector: 'my-placeholder-cmp', standalone: true, imports: [NgIf], template: '
Hi!
', }) class MyPlaceholderCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp], template: ` Before|@defer (when isVisible) {}|After `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('|After'); resetTViewsFor(SimpleComponent, MyPlaceholderCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp], template: ` @defer (when isVisible; prefetch on viewport(ref)) { } @placeholder {
Placeholder!
} `, }) 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(' { @Component({ selector: 'my-lazy-cmp', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ selector: 'my-placeholder-cmp', standalone: true, imports: [NgIf], template: '
Hi!
', }) class MyPlaceholderCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp, ProjectorCmp], template: ` Visible: {{ isVisible }}. @defer (when isVisible) { } @loading { Loading... } @placeholder { } @error { Failed to load dependencies :( } `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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', standalone: true, template: 'Hi!', }) class MyLazyCmp {} @Component({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ selector: 'my-placeholder-cmp', standalone: true, imports: [NgIf], template: '
Hi!
', }) class MyPlaceholderCmp {} @Component({ standalone: true, selector: 'app', imports: [MyLazyCmp, MyPlaceholderCmp, ProjectorCmp], template: ` Visible: {{ isVisible }}. @defer (when isVisible) { } @loading { Loading... } @placeholder { } @error { Failed to load dependencies :( } `, }) class SimpleComponent { isVisible = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, 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(''); }); it( 'should append skip hydration flag if component uses ShadowDom encapsulation ' + '(but keep parent and sibling elements hydratable)', async () => { @Component({ standalone: true, selector: 'shadow-dom', encapsulation: ViewEncapsulation.ShadowDom, template: `ShadowDom component`, styles: [':host { color: red; }'], }) class ShadowDomComponent {} @Component({ standalone: true, selector: 'regular', template: `

Regular component

`, }) class RegularComponent { @Input() id?: string; } @Component({ standalone: true, selector: 'app', imports: [RegularComponent, ShadowDomComponent], template: `
Main content
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); expect(ssrContents).toContain(''); }, ); }); describe('ngSkipHydration', () => { it('should skip hydrating elements with ngSkipHydration attribute', async () => { @Component({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This is the content of a nested component
`, }) class NestedComponent { @Input() title = ''; } @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: `
Main content
`, }) class SimpleComponent {} const indexHtml = '' + '' + ''; const html = await ssr(SimpleComponent, {doc: indexHtml}); const ssrContents = getAppContents(html); // No `ngh` attribute in the element. expect(ssrContents).toContain('
Main content
'); // 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(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({ standalone: true, selector: 'nested', imports: [NgIf], template: ` Hello world `, }) class Nested {} @Component({ standalone: true, selector: 'app', imports: [NgIf, Nested], template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'regular-cmp', template: ` `, }) class RegularCmp {} @Component({ standalone: true, selector: 'deeply-nested', host: {ngSkipHydration: 'true'}, template: ` `, }) class DeeplyNested {} @Component({ standalone: true, selector: 'deeply-nested-wrapper', host: {ngSkipHydration: 'true'}, imports: [RegularCmp], template: ` `, }) class DeeplyNestedWrapper {} @Component({ standalone: true, selector: 'layout', imports: [DeeplyNested, DeeplyNestedWrapper], template: ` `, }) class Layout {} @Component({ standalone: true, selector: 'app', imports: [NgIf, Layout], template: `

Hi!

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'dynamic-cmp', template: `DynamicComponent content`, }) class DynamicComponent {} @Component({ standalone: true, selector: 'regular-cmp', template: ` `, }) class RegularCmp {} @Component({ standalone: true, selector: 'deeply-nested', host: {ngSkipHydration: 'true'}, template: ` `, }) class DeeplyNested {} @Component({ standalone: true, selector: 'deeply-nested-wrapper', host: {ngSkipHydration: 'true'}, imports: [RegularCmp], template: ` `, }) class DeeplyNestedWrapper {} @Component({ standalone: true, selector: 'layout', imports: [DeeplyNested, DeeplyNestedWrapper], template: ` `, }) class Layout {} @Component({ standalone: true, selector: 'app', imports: [NgIf, Layout], template: `
`, }) 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(`(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({ standalone: true, selector: 'regular-cmp', template: ` `, }) class RegularCmp {} @Component({ standalone: true, selector: 'deeply-nested', host: {ngSkipHydration: 'true'}, template: ` `, }) class DeeplyNested {} @Component({ standalone: true, selector: 'deeply-nested-wrapper', host: {ngSkipHydration: 'true'}, imports: [RegularCmp], template: ` `, }) class DeeplyNestedWrapper {} @Component({ standalone: true, selector: 'layout', imports: [DeeplyNested, DeeplyNestedWrapper], template: ` `, }) class Layout {} @Component({ standalone: true, selector: 'app', imports: [NgIf, Layout], template: ` Hi! `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'nested', imports: [NgIf], template: ` Hello world `, }) class Nested {} @Component({ standalone: true, selector: 'app', imports: [NgIf, Nested], template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This is the content of a nested component
`, }) class NestedComponent { @Input() title = ''; } @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should skip hydrating elements with ngSkipHydration host binding', async () => { @Component({ standalone: true, selector: 'second-cmp', template: `
Not hydrated
`, }) class SecondCmd {} @Component({ standalone: true, imports: [SecondCmd], selector: 'nested-cmp', template: ``, host: {ngSkipHydration: 'true'}, }) class NestedCmp {} @Component({ standalone: true, imports: [NestedCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This is the content of a nested component
`, }) class NestedComponent { @Input() title = ''; } @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header

Dehydrated content header

This content is definitely dehydrated and could use some water.

Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This is the content of a nested component
`, }) class NestedComponent {} @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header

Dehydrated content header

Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This is the content of a nested component
`, }) class NestedComponent { el = inject(ElementRef); ngAfterViewInit() { const span = document.createElement('span'); span.innerHTML = 'Appended span'; this.el.nativeElement.appendChild(span); } } @Component({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'nested-cmp', template: `

Hello World!

This content will be removed

`, }) 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({ standalone: true, selector: 'app', imports: [NestedComponent], template: `
Header
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'nested-cmp', template: `

Just some text

`, }) 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({ standalone: true, selector: 'projector-cmp', imports: [NestedComponent], template: `
`, }) class ProjectorCmp { vcr = inject(ViewContainerRef); } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: `
Header
Footer
`, }) class SimpleComponent {} try { const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { @Directive({ standalone: true, selector: '[dir]', host: {ngSkipHydration: 'true'}, }) class Dir {} @Component({ standalone: true, selector: 'app', imports: [Dir], template: `
`, }) class SimpleComponent {} try { const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('… <-- AT THIS LOCATION', ); } }, ); }); describe('corrupted text nodes restoration', () => { it('should support empty text nodes', async () => { @Component({ standalone: true, selector: 'app', template: ` This is hydrated content. {{spanText}}.

{{pText}}

{{anotherText}}
`, }) class SimpleComponent { spanText = ''; pText = ''; anotherText = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: `
{{ text }}
`, }) class SimpleComponent { text = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' ` (1 text node with 2 empty spaces inside of // a
), 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(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({ standalone: true, selector: 'app', template: `
 {{ text }} 
   

Hello world!

   

Hello world!

`, }) class SimpleComponent { text = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: ` This is hydrated content.{{emptyText}}{{moreText}}{{andMoreText}}.

{{secondEmptyText}}{{secondMoreText}}

`, }) class SimpleComponent { emptyText = ''; moreText = ''; andMoreText = ''; secondEmptyText = ''; secondMoreText = ''; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', imports: [NgIf], template: `
Hello Angular World
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a SERVER-ONLY content This is a CLIENT-ONLY content `, }) 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('(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 cleanup unclaimed views in a component (when using s)', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a SERVER-ONLY content This is a CLIENT-ONLY content `, }) 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('(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 cleanup unclaimed views in a view container when ' + 'root component is used as an anchor for ViewContainerRef', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a SERVER-ONLY content (embedded view)
This is a CLIENT-ONLY content (embedded view)
This is a SERVER-ONLY content (root component) This is a CLIENT-ONLY content (root component) `, }) 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; 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('(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 (root component)', ); expect(ssrContents).not.toContain( '
This is a CLIENT-ONLY content (embedded view)
', ); expect(ssrContents).toContain('This is a SERVER-ONLY content (root component)'); expect(ssrContents).toContain( 'This is a SERVER-ONLY content (embedded view)', ); 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('This is a CLIENT-ONLY content (root component)'); expect(clientContents).toContain( '
This is a CLIENT-ONLY content (embedded view)
', ); expect(clientContents).not.toContain( 'This is a SERVER-ONLY content (root component)', ); expect(clientContents).not.toContain( 'This is a SERVER-ONLY content (embedded view)', ); }, ); it('should cleanup within inner containers', async () => { @Component({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a SERVER-ONLY content Outside of the container (must be retained). This is a CLIENT-ONLY content `, }) 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('(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'); 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('This is a CLIENT-ONLY content'); expect(clientContents).not.toContain('This is a SERVER-ONLY content'); // 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({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `
{{ item }} is bigger than 15!
Hi! This is the main content.
`, }) 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('(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( ' 5 is bigger than 15!', ); expect(postCleanupContents).toContain( ' 30 is bigger than 15!', ); expect(postCleanupContents).toContain(' 5 '); expect(postCleanupContents).toContain( ' 50 is bigger than 15!', ); }); it('should cleanup dehydrated views within dynamically created components', async () => { @Component({ standalone: true, imports: [CommonModule], selector: 'dynamic', template: ` This is a content of a dynamic component. This is a SERVER-ONLY content This is a CLIENT-ONLY content This is also a SERVER-ONLY content, but inside ng-container. With some extra tags and some text inside. `, }) class DynamicComponent { isServer = isPlatformServer(inject(PLATFORM_ID)); } @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `
Hi! This is the main content.
`, }) 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('(appRef); appRef.tick(); ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false)); // 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 trigger change detection after cleanup (immediate)', async () => { const observedChildCountLog: number[] = []; @Component({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a SERVER-ONLY content This is a CLIENT-ONLY content `, }) class SimpleComponent { isServer = isPlatformServer(inject(PLATFORM_ID)); elementRef = inject(ElementRef); constructor() { afterEveryRender(() => { observedChildCountLog.push(this.elementRef.nativeElement.childElementCount); }); } } const envProviders = [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); let ssrContents = getAppContents(html); expect(ssrContents).toContain(' { const observedChildCountLog: number[] = []; @Component({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is a SERVER-ONLY content This is a CLIENT-ONLY content `, }) 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 new Promise((resolve) => { setTimeout(resolve, 0); }); } } const envProviders = [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); let ssrContents = getAppContents(html); expect(ssrContents).toContain(' { it('should project plain text', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` Projected content is just a plain text. `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'mat-step', template: ``, }) class MatStep { @ViewChild(TemplateRef, {static: true}) content!: TemplateRef; } @Component({ standalone: true, selector: 'mat-stepper', imports: [NgTemplateOutlet], template: ` @for (step of steps; track step) { } `, }) class MatStepper { @ContentChildren(MatStep) steps!: QueryList; } @Component({ standalone: true, selector: 'nested-cmp', template: 'Nested cmp content', }) class NestedCmp {} @Component({ standalone: true, imports: [MatStepper, MatStep, NgIf, NestedCmp], selector: 'app', template: ` Text-only content Using ng-containers Using ng-containers with *ngIf @if (true) { Using built-in control flow (if) } `, }) class App {} const html = await ssr(App); const ssrContents = getAppContents(html); expect(ssrContents).toContain('`) 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(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should project plain text and HTML elements', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` Projected content is a plain text. Also the content has some tags `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support re-projection of contents', async () => { @Component({ standalone: true, selector: 'reprojector-cmp', template: `
`, }) class ReprojectorCmp {} @Component({ standalone: true, selector: 'projector-cmp', imports: [ReprojectorCmp], template: ` Before After `, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` Projected content is a plain text. `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: ` `, }) class ProjectorCmp {} @Component({selector: 'foo', standalone: true, template: ''}) class FooCmp {} @Component({selector: 'bar', standalone: true, template: ''}) class BarCmp {} @Component({ standalone: true, imports: [ProjectorCmp, FooCmp, BarCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: ` `, }) class ProjectorCmp {} @Component({selector: 'foo', standalone: true, template: ''}) class FooCmp {} @Component({selector: 'bar', standalone: true, template: ''}) class BarCmp {} @Component({ standalone: true, imports: [ProjectorCmp, FooCmp, BarCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should handle empty projection slots within ', async () => { @Component({ standalone: true, selector: 'projector-cmp', imports: [CommonModule], template: `
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it( 'should handle empty projection slots within ' + '(when no other elements are present)', async () => { @Component({ standalone: true, selector: 'projector-cmp', imports: [CommonModule], template: ` `, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: ` `, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }, ); it('should project contents into different slots', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: `
Header slot: Main slot: Footer slot:
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: `

H1

Footer
Header
Main

H2

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', imports: [CommonModule], template: ` {{ label }} `, }) class ProjectorCmp { label = 'Hi'; } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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 )', async () => { @Component({ standalone: true, selector: 'projector-cmp', imports: [CommonModule], template: ` {{ label }} `, }) class ProjectorCmp { label = 'Hi'; } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ` `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: `
Header slot: Main slot: Footer slot:
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: `

This node is not projected.

Footer
Header
Main

This node is not projected as well.

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should support cases when some element nodes are not projected', async () => { @Component({ standalone: true, selector: 'app-dropdown-content', template: ``, }) class DropdownContentComponent {} @Component({ standalone: true, selector: 'app-dropdown', template: ` @if (false) { } `, }) class DropdownComponent {} @Component({ standalone: true, imports: [DropdownComponent, DropdownContentComponent], selector: 'app-menu', template: ` `, }) class MenuComponent {} @Component({ selector: 'app', standalone: true, imports: [MenuComponent], template: ` Menu Content `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: `No content projection slots.`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: `

This node is not projected.

This node is not projected as well.

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: `No content projection slots.`, }) class ProjectorCmp {} @Component({ standalone: true, selector: 'nested', template: 'This is a nested component.', }) class NestedComponent {} @Component({ standalone: true, imports: [ProjectorCmp, NestedComponent], selector: 'app', template: `

This node is not projected.

This node is not projected as well.

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ standalone: true, selector: 'nested', template: 'No content projection slots.', }) class NestedComponent {} @Component({ standalone: true, imports: [ProjectorCmp, NestedComponent], selector: 'app', template: `

This node is not projected.

This node is not projected as well.

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); }); it("should project contents with *ngIf's", async () => { @Component({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp, CommonModule], selector: 'app', template: `

Header with an ngIf condition.

`, }) class SimpleComponent { visible = true; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should project contents with *ngFor', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: `
`, }) class ProjectorCmp {} @Component({ standalone: true, imports: [ProjectorCmp, CommonModule], selector: 'app', template: `

Item {{ item }}

`, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'dynamic-cmp', template: `
`, }) class DynamicComponent { @ViewChild('target', {read: ViewContainerRef}) vcRef!: ViewContainerRef; createView(tmplRef: TemplateRef) { this.vcRef.createEmbeddedView(tmplRef); } } @Component({ standalone: true, selector: 'projector-cmp', template: ` `, }) class ProjectorCmp { @ViewChild('ref', {read: TemplateRef}) tmplRef!: TemplateRef; 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({ standalone: true, imports: [ProjectorCmp, CommonModule], selector: 'app', template: `
Header
`, }) class SimpleComponent { visible = true; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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: ` `, }) class ShellCmp { @ContentChild('customTemplate', {static: true}) customTemplate: TemplateRef | null = null; } @Component({ imports: [ShellCmp], selector: 'app', template: `

template

`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'child-comp', template: '', }) class ChildComp {} @Component({ standalone: true, selector: 'root-comp', template: '', }) class RootComp {} @Component({ standalone: true, selector: 'app', imports: [CommonModule, RootComp, ChildComp], template: ` {{ item }}| `, }) class MyApp { items: number[] = [1, 2, 3]; } const html = await ssr(MyApp); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'dynamic', template: ` `, }) class DynamicComponent {} @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `
Hi! This is the main content.
`, }) 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(' <-- AT THIS LOCATION'); } }); it('should throw an error when projecting DOM nodes via createComponent function call', async () => { @Component({ standalone: true, selector: 'dynamic', template: ` `, }) class DynamicComponent {} @Component({ standalone: true, selector: 'app', imports: [NgIf, NgFor], template: `
Hi! This is the main content.
`, }) 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(' <-- AT THIS LOCATION'); } }); it('should support cases when is used with *ngIf="false"', async () => { @Component({ standalone: true, selector: 'projector-cmp', imports: [NgIf], template: ` Project?: {{ project ? 'yes' : 'no' }} `, }) class ProjectorCmp { @Input() project: boolean = false; } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: `

This node is not projected.

This node is not projected as well.

`, }) class SimpleComponent { project = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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 is used with *ngIf="true"', async () => { @Component({ standalone: true, selector: 'projector-cmp', imports: [NgIf], template: ` Project?: {{ project ? 'yes' : 'no' }} `, }) class ProjectorCmp { @Input() project: boolean = false; } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: `

This node is projected.

This node is projected as well.

`, }) class SimpleComponent { project = true; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'projector-cmp', template: `
Header slot: Header fallback Main slot:
Main fallback
Footer slot: Footer fallback {{expr}} Wildcard fallback
`, }) class ProjectorCmp { expr = 123; } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: ``, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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 fallback
'); 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({ standalone: true, selector: 'projector-cmp', template: `
Header slot: Header fallback Main slot:
Main fallback
Footer slot: Footer fallback {{expr}} Wildcard fallback
`, }) class ProjectorCmp { expr = 123; } @Component({ standalone: true, imports: [ProjectorCmp], selector: 'app', template: `
Header override

Footer override {{expr}}

`, }) class SimpleComponent { expr = 321; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; const content = clientRootNode.innerHTML; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(content).toContain('Header slot:
Header override
'); expect(content).toContain('Main slot:
Main fallback
'); expect(content).toContain( 'Footer slot:

Footer override 321

', ); expect(content).toContain('Wildcard fallback'); }); }); describe('unsupported Zone.js config', () => { it('should log a warning when a noop zone is used', async () => { @Component({ standalone: true, selector: 'app', template: `Hi!`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); verifyHasLog( 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 log a warning when a custom zone is used', async () => { @Component({ standalone: true, selector: 'app', template: `Hi!`, }) class SimpleComponent {} const envProviders = [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(appRef); appRef.tick(); verifyHasLog( 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); }); }); describe('error handling', () => { it('should handle text node mismatch', async () => { @Component({ standalone: true, selector: 'app', template: `
This is an original content
`, }) class SimpleComponent { private doc = inject(DOCUMENT); ngAfterViewInit() { const div = this.doc.querySelector('div'); div!.innerHTML = 'This is an extra span causing a problem!'; } } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a text node but found ', ); expect(message).toContain('#text(This is an original content) <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should not crash when a node can not be found during hydration', async () => { @Component({ standalone: true, selector: 'app', template: ` Some text.
This is an original content
`, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected
but the node was not found', ); expect(message).toContain('
<-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle element node mismatch', async () => { @Component({ standalone: true, selector: 'app', template: `

This is an original content

Bold text Italic text
`, }) 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(' { const message = (err as Error).message; expect(message).toContain('During hydration Angular expected but found '); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle node mismatch', async () => { @Component({ standalone: true, selector: 'app', template: ` Bold text

This is an original content

`, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found ', ); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); }); }); it( 'should handle node mismatch ' + '(when it is wrapped into a non-container node)', async () => { @Component({ standalone: true, selector: 'app', template: `

This is an original content

`, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found ', ); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); }); }, ); it('should handle node mismatch', async () => { @Component({ standalone: true, selector: 'app', imports: [CommonModule], template: ` Bold text Italic text `, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found ', ); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle node mismatches in nested components', async () => { @Component({ standalone: true, selector: 'nested-cmp', imports: [CommonModule], template: ` Bold text Italic text `, }) 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({ standalone: true, selector: 'app', imports: [NestedComponent], template: ``, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found ', ); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain('check the "NestedComponent" component'); verifyNodeHasMismatchInfo(doc, 'nested-cmp'); }); }); it('should handle sibling count mismatch', async () => { @Component({ standalone: true, selector: 'app', imports: [CommonModule], template: ` Bold text Italic text
Main content
`, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected more sibling nodes to be present', ); expect(message).toContain('
<-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle ViewContainerRef node mismatch', async () => { @Directive({ standalone: true, selector: 'b', }) class SimpleDir { vcr = inject(ViewContainerRef); } @Component({ standalone: true, selector: 'app', imports: [CommonModule, SimpleDir], template: ` Bold text `, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found ', ); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle a mismatch for a node that goes after a ViewContainerRef node', async () => { @Directive({ standalone: true, selector: 'b', }) class SimpleDir { vcr = inject(ViewContainerRef); } @Component({ standalone: true, selector: 'app', imports: [CommonModule, SimpleDir], template: ` Bold text Italic text `, }) 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(' { const message = (err as Error).message; expect(message).toContain( 'During hydration Angular expected a comment node but found ', ); expect(message).toContain(' <-- AT THIS LOCATION'); expect(message).toContain(' <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc); }); }); it('should handle a case when a node is not found (removed)', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: '', }) class ProjectorComponent {} @Component({ standalone: true, selector: 'app', imports: [CommonModule, ProjectorComponent], template: ` Bold text Italic text `, }) 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(' <-- AT THIS LOCATION'); verifyNodeHasMismatchInfo(doc, 'projector-cmp'); }); }); it('should handle a case when a node is not found (detached)', async () => { @Component({ standalone: true, selector: 'projector-cmp', template: '', }) class ProjectorComponent {} @Component({ standalone: true, selector: 'app', imports: [CommonModule, ProjectorComponent], template: ` Bold text `, }) 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(' { 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 node', ); verifyNodeHasMismatchInfo(doc, 'projector-cmp'); }); }); it('should handle a case when a node is not found (invalid DOM)', async () => { @Component({ standalone: true, selector: 'app', imports: [CommonModule], template: ` test `, }) class SimpleComponent {} try { const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' <-- 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({ standalone: true, 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('(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 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({ standalone: true, selector: 'app', template: `Hi!`, }) class SimpleComponent {} const html = ``; resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders: [withDebugConsole()], }); const compRef = getComponentRef(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({ standalone: true, 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({ standalone: true, selector: 'app', imports: [NgIf], template: ` This is NgIf SERVER-ONLY content This is NgIf CLIENT-ONLY content @if (isServer) { This is new if SERVER-ONLY content } @else { This is new if CLIENT-ONLY content } @if (alwaysTrue) {

CLIENT and SERVER content

} `, }) 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('This is new if CLIENT-ONLY content', ); expect(ssrContents).toContain('This is new if SERVER-ONLY content'); expect(ssrContents).not.toContain('This is NgIf CLIENT-ONLY content'); expect(ssrContents).toContain('This is NgIf SERVER-ONLY content'); // Content that should be rendered on both client and server should also be present. expect(ssrContents).toContain('

CLIENT and SERVER content

'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; expect(clientRootNode.outerHTML).not.toContain('This is NgIf SERVER-ONLY content'); expect(clientRootNode.outerHTML).not.toContain('This is new if SERVER-ONLY content'); 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( 'This is new if CLIENT-ONLY content', ); expect(clientContents).not.toContain('This is new if SERVER-ONLY content'); // Content that should be rendered on both client and server should still be present. expect(clientContents).toContain('

CLIENT and SERVER content

'); const clientOnlyNode1 = clientRootNode.querySelector('i'); const clientOnlyNode2 = clientRootNode.querySelector('#client-only'); verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode1, clientOnlyNode2]); }); it('should support nested `if`s', async () => { @Component({ standalone: true, selector: 'app', template: ` This is a non-empty block: @if (true) { @if (true) {

@if (true) { Hello world! }

} }
Post-container element
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate `else` blocks', async () => { @Component({ standalone: true, selector: 'app', template: ` @if (conditionA) { if block } @else { else block } `, }) class SimpleComponent { conditionA = false; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'app', imports: [NgSwitch, NgSwitchCase], template: ` This is NgSwitch SERVER-ONLY content This is NgSwitch CLIENT-ONLY content @switch (isServer) { @case (true) { This is a SERVER-ONLY content } @case (false) { This is a CLIENT-ONLY content } } `, }) 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)); ngOnInit() { setTimeout(() => {}, 100); } } const envProviders = [provideZoneChangeDetection() as any]; const html = await ssr(SimpleComponent, {envProviders}); let ssrContents = getAppContents(html); expect(ssrContents).toContain('This is a CLIENT-ONLY content'); expect(ssrContents).not.toContain('This is NgSwitch CLIENT-ONLY content'); expect(ssrContents).toContain('This is a SERVER-ONLY content'); expect(ssrContents).toContain('This is NgSwitch SERVER-ONLY content'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); const compRef = getComponentRef(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; // NgSwitch had slower cleanup than NgIf expect(clientRootNode.outerHTML).toContain('This is NgSwitch SERVER-ONLY content'); expect(clientRootNode.outerHTML).not.toContain('This is a SERVER-ONLY content'); expect(clientRootNode.outerHTML).toContain('This is a CLIENT-ONLY content'); expect(clientRootNode.outerHTML).toContain( 'This is NgSwitch CLIENT-ONLY content', ); 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).toContain('This is NgSwitch CLIENT-ONLY content'); expect(clientContents).not.toContain('This is NgSwitch SERVER-ONLY content'); expect(clientContents).not.toContain('This is a SERVER-ONLY content'); 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({ standalone: true, 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('(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({ standalone: true, selector: 'app', template: ` @for (item of items; track item) {

Item #{{ item }}

} `, }) class SimpleComponent { items = [1, 2, 3]; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); }); it('should hydrate @empty block content', async () => { @Component({ standalone: true, selector: 'app', template: ` @for (item of items; track item) {

Item #{{ item }}

} @empty {
This is an "empty" block
} `, }) class SimpleComponent { items = []; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(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({ standalone: true, selector: 'app', template: ` @for (item of items; track item) {

Item #{{ item }}

} @empty {
This is an "empty" block
} `, }) 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(`(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({ standalone: true, selector: 'app', template: ` @for (item of items; track item) {

Item #{{ item }}

} @empty {
This is an "empty" block
} `, }) 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(`(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({ standalone: true, selector: 'app', template: ` @for (item of items; track item) {

Item #{{ item }}

} `, }) 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(`(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', standalone: true, template: ` @for(item of items; track item) {
{{ item }}
} `, }) 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(`(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({ standalone: true, selector: 'app', template: ` @let greeting = name + '!!!'; Hello, {{greeting}} `, }) class SimpleComponent { name = 'Frodo'; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: ` @let plusOne = value + 1; @let plusTwo = plusOne + 1; @let result = plusTwo + 1; Result: {{result}} `, }) class SimpleComponent { value = 1; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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', standalone: true, }) class DoublePipe implements PipeTransform { changeDetectorRef = inject(ChangeDetectorRef); transform(value: number) { return value * 2; } } @Component({ standalone: true, selector: 'app', imports: [DoublePipe], template: ` @let result = value | double; Result: {{result}} `, }) class SimpleComponent { value = 1; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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({ standalone: true, selector: 'app', template: ` @if (true) { @if (true) { @let three = two + 1; The result is {{three}} } @let two = one + 1; } @let one = value + 1; `, }) class SimpleComponent { value = 0; } const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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: ` Fallback header Fallback content Fallback footer `, standalone: true, }) class InnerComponent {} @Component({ standalone: true, selector: 'app', template: ` @let one = 1;
|Footer value {{one}}
@let two = one + 1;
Header value {{two}}|
`, imports: [InnerComponent], }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); const expectedContent = '
Header value 2|
' + 'Fallback content' + '
|Footer value 1
'; expect(ssrContents).toContain('${expectedContent}`); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.innerHTML).toContain(`${expectedContent}`); }); it('should handle let declaration before and directly inside of an embedded view', async () => { @Component({ standalone: true, 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('(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({ standalone: true, 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(' before|after'); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(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({ standalone: true, selector: 'app', template: ` @let foo = ['foo']; @if (true) { {{foo}} } `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain('(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: Slot: ', }) class TestComponent {} @Component({ selector: 'app', imports: [TestComponent], template: ` @let a = 1; @let b = a + 1; {{b}} `, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(' Slot: 2', ); resetTViewsFor(SimpleComponent); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent); const compRef = getComponentRef(appRef); appRef.tick(); const clientRootNode = compRef.location.nativeElement; verifyAllNodesClaimedForHydration(clientRootNode); verifyClientAndSSRContentsMatch(ssrContents, clientRootNode); expect(clientRootNode.textContent).toContain('Main: Slot: 2'); }); }); describe('zoneless', () => { it('should not produce "unsupported configuration" warnings for zoneless mode', async () => { @Component({ standalone: true, selector: 'app', template: `
Header
Footer
`, }) class SimpleComponent {} const html = await ssr(SimpleComponent); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`(appRef); appRef.tick(); // Make sure there are no extra logs in case zoneless mode is enabled. 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); }); }); describe('Router', () => { it('should wait for lazy routes before triggering post-hydration cleanup', async () => { const ngZone = TestBed.inject(NgZone); @Component({ standalone: true, selector: 'lazy', template: `LazyCmp content`, }) class LazyCmp {} const routes: Routes = [ { path: '', loadComponent: () => { return ngZone.runOutsideAngular(() => { return new Promise((resolve) => { setTimeout(() => resolve(LazyCmp), 100); }); }); }, }, ]; @Component({ standalone: true, selector: 'app', imports: [RouterOutlet], template: ` Works! `, }) 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(`LazyCmp content`); resetTViewsFor(SimpleComponent, LazyCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); const compRef = getComponentRef(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({ standalone: true, selector: 'lazy', template: `LazyCmp content`, }) class LazyCmp {} const routes: Routes = [ { path: '', loadComponent: () => { return ngZone.runOutsideAngular(() => { return new Promise((resolve) => { setTimeout(() => resolve(LazyCmp), 100); }); }); }, }, ]; @Component({ standalone: true, selector: 'app', imports: [RouterOutlet], template: ` Works! `, }) class SimpleComponent {} const envProviders = [ provideZonelessChangeDetection(), {provide: PlatformLocation, useClass: MockPlatformLocation}, provideRouter(routes), ] as unknown as Provider[]; const html = await ssr(SimpleComponent, {envProviders}); const ssrContents = getAppContents(html); expect(ssrContents).toContain(`LazyCmp content`); resetTViewsFor(SimpleComponent, LazyCmp); const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { envProviders, }); const compRef = getComponentRef(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({ standalone: true, selector: 'cmp-a', template: ` @if (isServer) {

Server view

} @else {

Client view

} `, }) class CmpA { isServer = isPlatformServer(inject(PLATFORM_ID)); viewContainerRef = inject(ViewContainerRef); } const routes: Routes = [ { path: '', component: CmpA, }, ]; @Component({ standalone: true, selector: 'app', imports: [RouterOutlet], template: ` `, }) 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(`(appRef); appRef.tick(); await appRef.whenStable(); const clientRootNode = compRef.location.nativeElement; //

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