mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
This commit implements hydration support for view containers, which should make `*ngIf`, `*ngFor` and other structural directive work with hydration. The logic also respects the `ngSkipHydration` flag and skips hydration in such cases. PR Close #49382
1658 lines
55 KiB
TypeScript
1658 lines
55 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 {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet} from '@angular/common';
|
|
import {APP_ID, ApplicationRef, Component, ComponentRef, destroyPlatform, ElementRef, getPlatform, inject, Input, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵgetComponentDef as getComponentDef, ɵprovideHydrationSupport as provideHydrationSupport, ɵsetDocument} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {bootstrapApplication} from '@angular/platform-browser';
|
|
|
|
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 SKIP_HYDRATION_ATTR_NAME = 'ngSkipHydration';
|
|
const SKIP_HYDRATION_ATTR_NAME_LOWER_CASE = SKIP_HYDRATION_ATTR_NAME.toLowerCase();
|
|
const NGH_ATTR_REGEXP = new RegExp(` ${NGH_ATTR_NAME}=".*?"`, '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, '');
|
|
if (!keepNgh) {
|
|
html = html.replace(NGH_ATTR_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);
|
|
if (!result) {
|
|
throw new Error('Invalid HTML structure is provided.');
|
|
}
|
|
return result[1];
|
|
}
|
|
|
|
/**
|
|
* 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 stripTransferDataScript(input: string): string {
|
|
return input.replace(/<script (.*?)<\/script>/s, '');
|
|
}
|
|
|
|
function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootElement: HTMLElement) {
|
|
const clientContents =
|
|
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false));
|
|
ssrContents = stripTransferDataScript(stripUtilAttributes(ssrContents, false));
|
|
expect(clientContents).toBe(ssrContents, 'Client and server contents mismatch');
|
|
}
|
|
|
|
/**
|
|
* 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))
|
|
return;
|
|
|
|
if (!(el as any).__claimed) {
|
|
fail('Hydration error: the node is *not* hydrated: ' + el.outerHTML);
|
|
}
|
|
let current = el.firstChild;
|
|
while (current) {
|
|
verifyAllNodesClaimedForHydration(current as HTMLElement, exceptions);
|
|
current = current.nextSibling;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
describe('platform-server integration', () => {
|
|
beforeEach(() => {
|
|
if (getPlatform()) destroyPlatform();
|
|
});
|
|
|
|
afterAll(() => destroyPlatform());
|
|
|
|
describe('hydration', () => {
|
|
const appId = 'simple-cmp';
|
|
|
|
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[]): Promise<string> {
|
|
const defaultHtml = '<html><head></head><body><app></app></body></html>';
|
|
const providers = [
|
|
...(envProviders ?? []),
|
|
{provide: APP_ID, useValue: appId},
|
|
provideHydrationSupport(),
|
|
];
|
|
return renderApplication(component, {
|
|
document: doc ?? defaultHtml,
|
|
appId,
|
|
providers,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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[]):
|
|
Promise<ApplicationRef> {
|
|
// Destroy existing platform, a new one will be created later by the `bootstrapApplication`.
|
|
destroyPlatform();
|
|
|
|
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
|
|
const container = convertHtmlToDom(html, doc);
|
|
Array.from(container.children).forEach(node => doc.body.appendChild(node));
|
|
|
|
function _document(): any {
|
|
ɵsetDocument(doc);
|
|
global.document = doc; // needed for `DefaultDomRenderer2`
|
|
return doc;
|
|
}
|
|
|
|
const providers = [
|
|
...(envProviders ?? []),
|
|
{provide: APP_ID, useValue: appId},
|
|
{provide: DOCUMENT, useFactory: _document, deps: []},
|
|
provideHydrationSupport(),
|
|
];
|
|
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 hydation 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 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);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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 *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 *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}`);
|
|
|
|
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}`);
|
|
|
|
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}`);
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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 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 as any).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);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
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 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);
|
|
const compRef = getComponentRef<SimpleComponent>(appRef);
|
|
appRef.tick();
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|