mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
In certain cases Angular hydration logic can not rely on the order in which elements are present in a template (for example, in content-projection use-cases) and there is a need to serialize a path from one node to another, so that hydration can locate an element on a page. The logic attempts to use an immediate parent element as an anchor and compute the path from it. If it fails - the path is computed starting from the <body> (this is a fallback). This commit updates the logic to walk up the parents tree if an immediate parent (from a template) is disconnected from the DOM. This helps to shorten the lookup path and make it more stable. PR Close #53317
6650 lines
222 KiB
TypeScript
6650 lines
222 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.io/license
|
|
*/
|
|
|
|
import '@angular/localize/init';
|
|
|
|
import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common';
|
|
import {MockPlatformLocation} from '@angular/common/testing';
|
|
import {afterRender, ApplicationRef, Component, ComponentRef, ContentChildren, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, QueryList, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument, ɵwhenStable as whenStable} from '@angular/core';
|
|
import {Console} from '@angular/core/src/console';
|
|
import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
|
|
import {getComponentDef} from '@angular/core/src/render3/definition';
|
|
import {NoopNgZone} from '@angular/core/src/zone/ng_zone';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {bootstrapApplication, HydrationFeature, HydrationFeatureKind, provideClientHydration} from '@angular/platform-browser';
|
|
import {provideRouter, RouterOutlet, Routes} from '@angular/router';
|
|
|
|
import {provideServerRendering} from '../public_api';
|
|
import {renderApplication} from '../src/utils';
|
|
|
|
/**
|
|
* The name of the attribute that contains a slot index
|
|
* inside the TransferState storage where hydration info
|
|
* could be found.
|
|
*/
|
|
const NGH_ATTR_NAME = 'ngh';
|
|
const EMPTY_TEXT_NODE_COMMENT = 'ngetn';
|
|
const TEXT_NODE_SEPARATOR_COMMENT = 'ngtns';
|
|
|
|
const SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration';
|
|
const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase();
|
|
|
|
const TRANSFER_STATE_TOKEN_ID = '__nghData__';
|
|
|
|
const NGH_ATTR_REGEXP = new RegExp(` ${NGH_ATTR_NAME}=".*?"`, 'g');
|
|
const EMPTY_TEXT_NODE_REGEXP = new RegExp(`<!--${EMPTY_TEXT_NODE_COMMENT}-->`, 'g');
|
|
const TEXT_NODE_SEPARATOR_REGEXP = new RegExp(`<!--${TEXT_NODE_SEPARATOR_COMMENT}-->`, 'g');
|
|
|
|
/**
|
|
* Drop utility attributes such as `ng-version`, `ng-server-context` and `ngh`,
|
|
* so that it's easier to make assertions in tests.
|
|
*/
|
|
function stripUtilAttributes(html: string, keepNgh: boolean): string {
|
|
html = html.replace(/ ng-version=".*?"/g, '')
|
|
.replace(/ ng-server-context=".*?"/g, '')
|
|
.replace(/ ng-reflect-(.*?)=".*?"/g, '')
|
|
.replace(/ _nghost(.*?)=""/g, '')
|
|
.replace(/ _ngcontent(.*?)=""/g, '');
|
|
if (!keepNgh) {
|
|
html = html.replace(NGH_ATTR_REGEXP, '')
|
|
.replace(EMPTY_TEXT_NODE_REGEXP, '')
|
|
.replace(TEXT_NODE_SEPARATOR_REGEXP, '');
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function getComponentRef<T>(appRef: ApplicationRef): ComponentRef<T> {
|
|
return appRef.components[0];
|
|
}
|
|
|
|
/**
|
|
* Extracts a portion of HTML located inside of the `<body>` element.
|
|
* This content belongs to the application view (and supporting TransferState
|
|
* scripts) rendered on the server.
|
|
*/
|
|
function getAppContents(html: string): string {
|
|
const result = stripUtilAttributes(html, true).match(/<body>(.*?)<\/body>/s);
|
|
return result ? result[1] : html;
|
|
}
|
|
|
|
/**
|
|
* Converts a static HTML to a DOM structure.
|
|
*
|
|
* @param html the rendered html in test
|
|
* @param doc the document object
|
|
* @returns a div element containing a copy of the app contents
|
|
*/
|
|
function convertHtmlToDom(html: string, doc: Document): HTMLElement {
|
|
const contents = getAppContents(html);
|
|
const container = doc.createElement('div');
|
|
container.innerHTML = contents;
|
|
return container;
|
|
}
|
|
|
|
function stripSsrIntegrityMarker(input: string): string {
|
|
return input.replace(`<!--${SSR_CONTENT_INTEGRITY_MARKER}-->`, '');
|
|
}
|
|
|
|
function stripTransferDataScript(input: string): string {
|
|
return input.replace(/<script (.*?)<\/script>/s, '');
|
|
}
|
|
|
|
function stripExcessiveSpaces(html: string): string {
|
|
return html.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootElement: HTMLElement) {
|
|
const clientContents = stripSsrIntegrityMarker(
|
|
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false)));
|
|
ssrContents =
|
|
stripSsrIntegrityMarker(stripTransferDataScript(stripUtilAttributes(ssrContents, false)));
|
|
expect(getAppContents(clientContents)).toBe(ssrContents, 'Client and server contents mismatch');
|
|
}
|
|
|
|
/** Checks whether a given element is a <script> that contains transfer state data. */
|
|
function isTransferStateScript(el: HTMLElement): boolean {
|
|
return el.nodeType === Node.ELEMENT_NODE && el.tagName.toLowerCase() === 'script' &&
|
|
el.getAttribute('id') === 'ng-state';
|
|
}
|
|
|
|
function isSsrContentsIntegrityMarker(el: Node): boolean {
|
|
return el.nodeType === Node.COMMENT_NODE &&
|
|
el.textContent?.trim() === SSR_CONTENT_INTEGRITY_MARKER;
|
|
}
|
|
|
|
/**
|
|
* Walks over DOM nodes starting from a given node and checks
|
|
* whether all nodes were claimed for hydration, i.e. annotated
|
|
* with a special monkey-patched flag (which is added in dev mode
|
|
* only). It skips any nodes with the skip hydration attribute.
|
|
*/
|
|
function verifyAllNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElement[] = []) {
|
|
if ((el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(SKIP_HYDRATION_ATTR_NAME_LOWER_CASE)) ||
|
|
exceptions.includes(el) || isTransferStateScript(el) || isSsrContentsIntegrityMarker(el)) {
|
|
return;
|
|
}
|
|
|
|
if (!(el as any).__claimed) {
|
|
fail('Hydration error: the node is *not* hydrated: ' + el.outerHTML);
|
|
}
|
|
verifyAllChildNodesClaimedForHydration(el, exceptions);
|
|
}
|
|
|
|
function verifyAllChildNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElement[] = []) {
|
|
let current = el.firstChild;
|
|
while (current) {
|
|
verifyAllNodesClaimedForHydration(current as HTMLElement, exceptions);
|
|
current = current.nextSibling;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walks over DOM nodes starting from a given node and make sure
|
|
* those nodes were not annotated as "claimed" by hydration.
|
|
* This helper function is needed to verify that the non-destructive
|
|
* hydration feature can be turned off.
|
|
*/
|
|
function verifyNoNodesWereClaimedForHydration(el: HTMLElement) {
|
|
if ((el as any).__claimed) {
|
|
fail(
|
|
'Unexpected state: the following node was hydrated, when the test ' +
|
|
'expects the node to be re-created instead: ' + el.outerHTML);
|
|
}
|
|
let current = el.firstChild;
|
|
while (current) {
|
|
verifyNoNodesWereClaimedForHydration(current as HTMLElement);
|
|
current = current.nextSibling;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies whether a console has a log entry that contains a given message.
|
|
*/
|
|
function verifyHasLog(appRef: ApplicationRef, message: string) {
|
|
const console = appRef.injector.get(Console) as DebugConsole;
|
|
const context = `Expected '${message}' to be present in the log, but it was not found. ` +
|
|
`Logs content: ${JSON.stringify(console.logs)}`;
|
|
expect(console.logs.some(log => log.includes(message))).withContext(context).toBe(true);
|
|
}
|
|
|
|
/**
|
|
* Verifies that there is no message with a particular content in a console.
|
|
*/
|
|
function verifyHasNoLog(appRef: ApplicationRef, message: string) {
|
|
const console = appRef.injector.get(Console) as DebugConsole;
|
|
const context = `Expected '${message}' to be present in the log, but it was not found. ` +
|
|
`Logs content: ${JSON.stringify(console.logs)}`;
|
|
expect(console.logs.some(log => log.includes(message))).withContext(context).toBe(false);
|
|
}
|
|
|
|
|
|
/**
|
|
* Reset TView, so that we re-enter the first create pass as
|
|
* we would normally do when we hydrate on the client. Otherwise,
|
|
* hydration info would not be applied to T data structures.
|
|
*/
|
|
function resetTViewsFor(...types: Type<unknown>[]) {
|
|
for (const type of types) {
|
|
getComponentDef(type)!.tView = null;
|
|
}
|
|
}
|
|
|
|
function getHydrationInfoFromTransferState(input: string): string|undefined {
|
|
return input.match(/<script[^>]+>(.*?)<\/script>/)?.[1];
|
|
}
|
|
|
|
function withNoopErrorHandler() {
|
|
class NoopErrorHandler extends ErrorHandler {
|
|
override handleError(error: any): void {
|
|
// noop
|
|
}
|
|
}
|
|
return [{
|
|
provide: ErrorHandler,
|
|
useClass: NoopErrorHandler,
|
|
}];
|
|
}
|
|
|
|
@Injectable()
|
|
class DebugConsole extends Console {
|
|
logs: string[] = [];
|
|
override log(message: string) {
|
|
this.logs.push(message);
|
|
}
|
|
override warn(message: string) {
|
|
this.logs.push(message);
|
|
}
|
|
}
|
|
|
|
function withDebugConsole() {
|
|
return [{provide: Console, useClass: DebugConsole}];
|
|
}
|
|
|
|
describe('platform-server hydration integration', () => {
|
|
beforeEach(() => {
|
|
if (typeof ngDevMode === 'object') {
|
|
// Reset all ngDevMode counters.
|
|
for (const metric of Object.keys(ngDevMode!)) {
|
|
const currentValue = (ngDevMode as unknown as {[key: string]: number | boolean})[metric];
|
|
if (typeof currentValue === 'number') {
|
|
// Rest only numeric values, which represent counters.
|
|
(ngDevMode as unknown as {[key: string]: number | boolean})[metric] = 0;
|
|
}
|
|
}
|
|
}
|
|
if (getPlatform()) destroyPlatform();
|
|
});
|
|
|
|
afterAll(() => destroyPlatform());
|
|
|
|
describe('hydration', () => {
|
|
let doc: Document;
|
|
|
|
beforeEach(() => {
|
|
doc = TestBed.inject(DOCUMENT);
|
|
});
|
|
|
|
afterEach(() => {
|
|
doc.body.textContent = '';
|
|
});
|
|
|
|
/**
|
|
* This renders the application with server side rendering logic.
|
|
*
|
|
* @param component the test component to be rendered
|
|
* @param doc the document
|
|
* @param envProviders the environment providers
|
|
* @returns a promise containing the server rendered app as a string
|
|
*/
|
|
async function ssr(
|
|
component: Type<unknown>, doc?: string, envProviders?: Provider[],
|
|
hydrationFeatures: HydrationFeature<HydrationFeatureKind>[] = [],
|
|
enableHydration = true): Promise<string> {
|
|
const defaultHtml = '<html><head></head><body><app></app></body></html>';
|
|
const providers = [
|
|
...(envProviders ?? []),
|
|
provideServerRendering(),
|
|
(enableHydration ? provideClientHydration(...hydrationFeatures) : []),
|
|
];
|
|
|
|
const bootstrap = () => bootstrapApplication(component, {providers});
|
|
|
|
return renderApplication(bootstrap, {
|
|
document: doc ?? defaultHtml,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* This bootstraps an application with existing html and enables hydration support
|
|
* causing hydration to be invoked.
|
|
*
|
|
* @param html the server side rendered DOM string to be hydrated
|
|
* @param component the root component
|
|
* @param envProviders the environment providers
|
|
* @returns a promise with the application ref
|
|
*/
|
|
async function hydrate(
|
|
html: string, component: Type<unknown>, envProviders?: Provider[],
|
|
hydrationFeatures: HydrationFeature<HydrationFeatureKind>[] = []): Promise<ApplicationRef> {
|
|
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
|
|
const container = convertHtmlToDom(html, doc);
|
|
Array.from(container.childNodes).forEach(node => doc.body.appendChild(node));
|
|
|
|
function _document(): any {
|
|
ɵsetDocument(doc);
|
|
global.document = doc; // needed for `DefaultDomRenderer2`
|
|
return doc;
|
|
}
|
|
|
|
const providers = [
|
|
...(envProviders ?? []),
|
|
{provide: DOCUMENT, useFactory: _document, deps: []},
|
|
provideClientHydration(...hydrationFeatures),
|
|
];
|
|
|
|
return bootstrapApplication(component, {providers});
|
|
}
|
|
|
|
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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(html, SimpleComponent, [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 hydrate(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 doc = `<html><head></head><body><app>${extraChildNodes}</app></body></html>`;
|
|
const html = await ssr(SimpleComponent, doc);
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
|
|
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
const appRef = await hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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, indexHtml);
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(ssrContents).toContain('<app ngh');
|
|
|
|
resetTViewsFor(SimpleComponent, DynamicComponent);
|
|
|
|
const appRef = await hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 hydrate(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 hydrate(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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
const clientRootNode = compRef.location.nativeElement;
|
|
verifyAllNodesClaimedForHydration(clientRootNode);
|
|
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
// Note: hydration for i18n blocks is not *yet* 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('i18n', () => {
|
|
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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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 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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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, undefined, 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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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 hydrate(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, 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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(html, SimpleComponent, [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 hydrate(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() {
|
|
const pTag = document.querySelector('p');
|
|
pTag?.parentElement?.removeChild(pTag);
|
|
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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
const clientRootNode = compRef.location.nativeElement;
|
|
verifyAllNodesClaimedForHydration(clientRootNode);
|
|
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
|
|
});
|
|
|
|
it('should not treat text nodes with ` `s as empty', async () => {
|
|
@Component({
|
|
standalone: true,
|
|
selector: 'app',
|
|
template: `
|
|
<div> {{ text }} </div>
|
|
|
|
<h1>Hello world!</h1>
|
|
|
|
<h2>Hello world!</h2>
|
|
`,
|
|
})
|
|
class SimpleComponent {
|
|
text = '';
|
|
}
|
|
|
|
const html = await ssr(SimpleComponent);
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(ssrContents).toContain('<app ngh');
|
|
|
|
// Expect special markers to not be present, since there
|
|
// are no corrupted text nodes that require restoring.
|
|
expect(ssrContents).not.toContain(EMPTY_TEXT_NODE_COMMENT);
|
|
expect(ssrContents).not.toContain(TEXT_NODE_SEPARATOR_COMMENT);
|
|
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
const appRef = await hydrate(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 hydrate(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 hydrate(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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
const clientRootNode = compRef.location.nativeElement;
|
|
|
|
await whenStable(appRef);
|
|
|
|
// Post-cleanup should *not* contain dehydrated views.
|
|
const postCleanupContents = stripExcessiveSpaces(clientRootNode.outerHTML);
|
|
expect(postCleanupContents)
|
|
.not.toContain(
|
|
'<span> 5 <b>is bigger than 15!</b><!--bindings={ "ng-reflect-ng-if": "false" }--></span>');
|
|
expect(postCleanupContents)
|
|
.toContain(
|
|
'<span> 30 <b>is bigger than 15!</b><!--bindings={ "ng-reflect-ng-if": "true" }--></span>');
|
|
expect(postCleanupContents)
|
|
.toContain('<span> 5 <!--bindings={ "ng-reflect-ng-if": "false" }--></span>');
|
|
expect(postCleanupContents)
|
|
.toContain(
|
|
'<span> 50 <b>is bigger than 15!</b><!--bindings={ "ng-reflect-ng-if": "true" }--></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 hydrate(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 whenStable(appRef);
|
|
|
|
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() {
|
|
afterRender(() => {
|
|
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 hydrate(html, SimpleComponent);
|
|
await whenStable(appRef);
|
|
|
|
// 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() {
|
|
afterRender(() => {
|
|
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 hydrate(html, SimpleComponent);
|
|
|
|
// afterRender should be triggered by:
|
|
// 1.) Bootstrap
|
|
// 2.) Microtask empty event
|
|
expect(observedChildCountLog).toEqual([2, 2]);
|
|
|
|
await whenStable(appRef);
|
|
|
|
// 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 hydrate(html, SimpleComponent, [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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 hydrate(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 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 hydrate(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 hydrate(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 hydrate(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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, [
|
|
{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 hydrate(html, SimpleComponent, [
|
|
{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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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 hydrate(html, SimpleComponent, 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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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, undefined, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(html, SimpleComponent, 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');
|
|
});
|
|
});
|
|
|
|
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 hydrate(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');
|
|
}
|
|
});
|
|
|
|
it('should log an 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, undefined, undefined, undefined, false);
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(ssrContents).not.toContain('<app ngh');
|
|
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
const appRef = await hydrate(html, SimpleComponent, [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);
|
|
});
|
|
});
|
|
|
|
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',
|
|
template: `
|
|
@if (isServer) { <b>This is a SERVER-ONLY content</b> }
|
|
@if (!isServer) { <i>This is a 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));
|
|
}
|
|
|
|
const html = await ssr(SimpleComponent);
|
|
let ssrContents = getAppContents(html);
|
|
|
|
expect(ssrContents).toContain('<app ngh');
|
|
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
const appRef = await hydrate(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>');
|
|
|
|
// Content that should be rendered on both client and server should also be present.
|
|
expect(ssrContents).toContain('<p>CLIENT and SERVER content</p>');
|
|
|
|
const clientRootNode = compRef.location.nativeElement;
|
|
|
|
await whenStable(appRef);
|
|
|
|
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>');
|
|
|
|
// Content that should be rendered on both client and server should still be present.
|
|
expect(clientContents).toContain('<p>CLIENT and SERVER content</p>');
|
|
|
|
const clientOnlyNode = clientRootNode.querySelector('i');
|
|
verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode]);
|
|
});
|
|
|
|
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 hydrate(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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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',
|
|
template: `
|
|
@switch (isServer) {
|
|
@case (true) { <b>This is a SERVER-ONLY content</b> }
|
|
@case (false) { <i>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 hydrate(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 whenStable(appRef);
|
|
|
|
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>');
|
|
|
|
const clientOnlyNode = clientRootNode.querySelector('i');
|
|
verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode]);
|
|
});
|
|
|
|
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 hydrate(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 whenStable(appRef);
|
|
|
|
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 hydrate(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 hydrate(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];
|
|
}
|
|
|
|
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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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).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] : [];
|
|
}
|
|
|
|
const html = await ssr(SimpleComponent);
|
|
const ssrContents = getAppContents(html);
|
|
|
|
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
|
|
|
|
resetTViewsFor(SimpleComponent);
|
|
|
|
// 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');
|
|
|
|
const appRef = await hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
const clientRootNode = compRef.location.nativeElement;
|
|
|
|
// 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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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 hydrate(html, SimpleComponent);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
await whenStable(appRef);
|
|
|
|
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('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 providers = [
|
|
{provide: PlatformLocation, useClass: MockPlatformLocation},
|
|
provideRouter(routes),
|
|
] as unknown as Provider[];
|
|
const html = await ssr(SimpleComponent, undefined, providers);
|
|
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 hydrate(html, SimpleComponent, providers);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
const clientRootNode = compRef.location.nativeElement;
|
|
|
|
await whenStable(appRef);
|
|
|
|
verifyAllNodesClaimedForHydration(clientRootNode);
|
|
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
|
|
});
|
|
});
|
|
});
|
|
});
|