angular/packages/platform-server/test/full_app_hydration_spec.ts
Jessica Janiuk 8424b3bcd5 fix(core): Fixes template outlet hydration (#61989)
Projected nodes were missing ssrId information and were skipping annotating template information, which caused templates to be destroyed and recreated rather than hydrated.

fixes: #50543

PR Close #61989
2025-06-12 12:53:21 +02:00

8290 lines
281 KiB
TypeScript

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