Files
arturovt 7546b7a805 fix(core): throw coded RuntimeErrors instead of crashing when hydration/rendering can't find an expected DOM node
This started from a real production crash trace:

    TypeError: Cannot read properties of null (reading 'nextSibling')

Traced through a minified bundle back to siblingAfter() in
packages/core/src/hydration/node_lookup_utils.ts. It walks forward a
fixed number of DOM siblings during hydration, based on how many the
server rendered. Its only guard, validateSiblingNodeExists(), is
gated behind ngDevMode and stripped from production builds. If the
client DOM has fewer real siblings than the server-serialized data
expects — client/server render diverged, or something outside
Angular (a browser extension, an injected script) removed a node —
the loop ran fully unguarded in production: currentNode went null
partway through, and the next iteration's currentNode.nextSibling
threw a raw, uncoded TypeError instead of a coded, debuggable one.

Reproduced first, not just theorized: added a test that server-
renders a @for loop with 3 items, removes 2 of them before hydration
runs, and confirmed it actually throws that exact TypeError against
the original code before touching anything.

Fix: add a null check at the top of the loop, matching the pattern
already used for the existing NG0500/NG0502 hydration checks in
element.ts — the dev-mode check runs first as before (a no-op in
production), and the new check is a pure safety net that only fires
once that dev-mode check has already been compiled away. In dev mode
this is provably dead code, so nothing about dev behavior changes.
Coded as NG0501 (HYDRATION_MISSING_SIBLINGS, already existed).

While investigating, found and fixed two more call sites with the
exact same shape of bug — a dev-only check that leaves production
completely unguarded:

- navigateToNode() (same file): walks an explicit "firstChild /
  nextSibling" navigation path recorded for nodes that can't rely on
  simple sibling-walking (e.g. content right after an <ng-content>
  slot). Its only guard was also ngDevMode-only. Added the same
  production safety net, both mid-loop (the raw-crash case) and
  post-loop (where it could previously return null silently despite
  the function's return type promising a non-null RNode). New code:
  NG0509 (HYDRATION_MISSING_NODE_ON_PATH).

- getParentRElement() (render3/node_manipulation.ts): typed its
  tNode parameter as always non-null, but a real production trace
  showed it can be null at runtime, crashing on tNode.parent with no
  useful information. Widened the type to TNode | null and added a
  guard that throws a coded error instead. New code: NG0510
  (PARENT_NODE_NOT_FOUND).

  Unlike the other two, this one isn't gated behind ngDevMode at all
  in the original code — it reproduces unconditionally. Decoded the
  actual production minified stack trace against this source
  (matched every frame character-for-character) to find the real
  cause instead of guessing: an @if/@switch branch's content is its
  own embedded template with its own TView, built lazily the first
  time that branch renders. If an error interrupts that first pass —
  here, a hydration mismatch on the branch's second child, after the
  first child's TNode was already created — TView.firstCreatePass
  still gets flipped to false in render.ts's `catch` block before the
  error propagates, permanently marking that TView as corrupted.
  Unlike a component's TView (rebuilt from scratch next time via
  getOrCreateComponentTView()'s incompleteFirstPass check), nothing
  rebuilds an embedded view's TView. The next time that exact branch
  is selected again, its instructions read straight from the
  corrupted tView.data instead of creating a fresh TNode, and any
  node past the interruption point is still null.

  The real test added for this (full_app_hydration_spec.ts) exercises
  that whole chain for real: a hydration mismatch on an @if branch's
  second child, then re-entering the same branch. Confirmed it throws
  the exact byte-for-byte production error message ("Cannot read
  properties of null (reading 'parent')") without the fix, and the
  coded NG0510 with it. This replaces an earlier synthetic unit test
  that just cast a variable to null to bypass the type system rather
  than reproducing the actual failure.

  Note: a more thorough fix would rebuild the corrupted embedded TView
  itself (mirroring getOrCreateComponentTView()), which does make the
  crash disappear — but doing that surfaces a second, separate bug:
  the aborted first attempt's partially-created LView/DOM is never
  torn down, so the retried branch's content gets appended alongside
  leftover orphaned nodes instead of replacing them, corrupting
  content silently instead of crashing loudly. That's a materially
  bigger fix (proper LView cleanup after a mid-creation exception) and
  is intentionally out of scope here; this commit keeps the narrower,
  safe fix (a clear coded error) rather than trading a loud crash for
  silent data corruption.

Along the way, deduplicated describeDomNode(), which existed as two
near-identical private copies (one in this file's own NG0500 check,
one in platform-browser's dom_renderer.ts insertBefore check) and
moved it into core's hydration/error_handling.ts, shared via the
private ɵdescribeDomNode export.

That function was then removed from every production code path
entirely, per review feedback: computing a human-readable node
description is debug-oriented work that shouldn't ship unconditionally
in production bundles, regardless of how cheap it is — production
should get the bare coded error only, the same as everywhere else in
this file. All three fixes above follow that: ngDevMode && 'message'
in production, no computed description. Confirmed the removal
actually shrinks output, not just assumed it: regenerated all 8
affected bundling symbol goldens, and each one shows exactly one
symbol removed — "describeDomNode" — and nothing else.

(cherry picked from commit 83f7695b2e)
2026-08-25 16:16:27 -07:00

8228 lines
286 KiB
TypeScript

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