/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import '@angular/localize/init';

import {
  CommonModule,
  DOCUMENT,
  isPlatformBrowser,
  isPlatformServer,
  NgComponentOutlet,
  NgFor,
  NgIf,
  NgSwitch,
  NgSwitchCase,
  NgTemplateOutlet,
  PlatformLocation,
} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {computeMsgId} from '@angular/compiler';
import {
  afterEveryRender,
  ApplicationRef,
  ChangeDetectorRef,
  ɵ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', () => {
  beforeEach(() => {
    resetNgDevModeCounters();
  });

  afterEach(() => {
    destroyPlatform();
  });

  describe('hydration', () => {
    let doc: Document;

    beforeEach(() => {
      doc = TestBed.inject(DOCUMENT);
      clearConsole(TestBed.inject(ApplicationRef));
    });

    afterEach(() => {
      clearDocument(doc);
      clearConsole(TestBed.inject(ApplicationRef));
    });

    describe('annotations', () => {
      it('should add hydration annotations to component host nodes during ssr', async () => {
        @Component({
          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>');
      });
    });

    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" />
          `,
        })
        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>
          `,
        })
        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" />
          `,
        })
        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>
          `,
        })
        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 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
            }
          `,
        })
        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 }}
          `,
        })
        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 }}
          `,
        })
        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 }}
          `,
        })
        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;
          `,
        })
        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');
      });
    });
  });
});
