/**
 * @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 {
  APP_ID,
  ApplicationRef,
  Component,
  ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY,
  destroyPlatform,
  ɵgetDocument as getDocument,
  inject,
  Input,
  ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP,
  ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT,
  PendingTasks,
  PLATFORM_ID,
  Provider,
  QueryList,
  ɵresetIncrementalHydrationEnabledWarnedForTests as resetIncrementalHydrationEnabledWarnedForTests,
  signal,
  ɵTimerScheduler as TimerScheduler,
  ViewChildren,
  ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
} from '@angular/core';

import {
  DOCUMENT,
  isPlatformServer,
  Location,
  ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID,
  PlatformLocation,
} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {TestBed} from '@angular/core/testing';
import {
  provideClientHydration,
  withEventReplay,
  withIncrementalHydration,
} from '@angular/platform-browser';
import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router';
import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils';
import {
  clearConsole,
  getComponentRef,
  resetNgDevModeCounters,
  ssr,
  timeout,
  verifyHasLog,
  verifyNodeWasHydrated,
  verifyNodeWasNotHydrated,
  withDebugConsole,
} from './hydration_utils';

/**
 * Emulates a dynamic import promise.
 *
 * Note: `setTimeout` is used to make `fixture.whenStable()` function
 * wait for promise resolution, since `whenStable()` relies on the state
 * of a macrotask queue.
 */
function dynamicImportOf<T>(type: T, timeout = 0): Promise<T> {
  return new Promise<T>((resolve) => {
    setTimeout(() => {
      resolve(type);
    }, timeout);
  });
}

/**
 * Emulates a failed dynamic import promise.
 */
function failedDynamicImport(): Promise<void> {
  return new Promise((_, reject) => {
    setTimeout(() => reject());
  });
}

/**
 * Helper function to await all pending dynamic imports
 * emulated using `dynamicImportOf` function.
 */
function allPendingDynamicImports() {
  return dynamicImportOf(null, 101);
}

describe('platform-server partial hydration integration', () => {
  const originalWindow = globalThis.window;

  beforeAll(async () => {
    globalThis.window = globalThis as unknown as Window & typeof globalThis;
    await import('../../core/primitives/event-dispatch/contract_bundle_min.js' as string);
  });

  afterAll(() => {
    globalThis.window = originalWindow;
  });

  afterEach(() => {
    destroyPlatform();
    window._ejsas = {};
  });

  describe('core functionality', () => {
    beforeEach(() => {
      clearConsole(TestBed.inject(ApplicationRef));
      resetNgDevModeCounters();
    });

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

    describe('annotation', () => {
      it('should annotate inner components with defer block id', async () => {
        @Component({
          selector: 'dep-a',
          template: '<button (click)="null">Click A</button>',
        })
        class DepA {}

        @Component({
          selector: 'dep-b',
          imports: [DepA],
          template: `
            <dep-a />
            <button (click)="(null)">Click B</button>
          `,
        })
        class DepB {}

        @Component({
          selector: 'app',
          imports: [DepB],
          template: `
            <main (click)="fnA()">
              @defer (on viewport; hydrate on interaction) {
                <div (click)="fnA()">
                  Main defer block rendered!
                  @if (visible) {
                    Defer events work!
                  }
                  <div id="outer-trigger" (mouseover)="showMessage()"></div>
                  @defer (on viewport; hydrate on interaction) {
                    <p (click)="fnA()">Nested defer block</p>
                    <dep-b />
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                </div>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          items = [1, 2, 3];
          visible = false;
          fnA() {}
          showMessage() {
            this.visible = true;
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        expect(ssrContents).toContain('<main jsaction="click:;">');
        // Buttons inside nested components inherit parent defer block namespace.
        expect(ssrContents).toContain('<button jsaction="click:;" ngb="d1">Click A</button>');
        expect(ssrContents).toContain('<button jsaction="click:;" ngb="d1">Click B</button>');
        expect(ssrContents).toContain('<!--ngh=d0-->');
        expect(ssrContents).toContain('<!--ngh=d1-->');
      });

      it('should not include trigger array when only JSAction triggers are present', async () => {
        @Component({
          selector: 'dep-a',
          template: '<button (click)="null">Click A</button>',
        })
        class DepA {}

        @Component({
          selector: 'dep-b',
          imports: [DepA],
          template: `
            <dep-a />
            <button (click)="(null)">Click B</button>
          `,
        })
        class DepB {}

        @Component({
          selector: 'app',
          imports: [DepB],
          template: `
            <main (click)="fnA()">
              @defer (on viewport; hydrate on interaction) {
                <div (click)="fnA()">
                  Main defer block rendered!
                  @if (visible) {
                    Defer events work!
                  }
                  <div id="outer-trigger" (mouseover)="showMessage()"></div>
                  @defer (on viewport; hydrate on interaction) {
                    <p (click)="fnA()">Nested defer block</p>
                    <dep-b />
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                </div>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          items = [1, 2, 3];
          visible = false;
          fnA() {}
          showMessage() {
            this.visible = true;
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":2,"s":2,"p":"d0"}}',
        );
      });

      it('should include trigger array for non-jsaction triggers', async () => {
        @Component({
          selector: 'dep-a',
          template: '<button (click)="null">Click A</button>',
        })
        class DepA {}

        @Component({
          selector: 'dep-b',
          imports: [DepA],
          template: `
            <dep-a />
            <button (click)="(null)">Click B</button>
          `,
        })
        class DepB {}

        @Component({
          selector: 'app',
          imports: [DepB],
          template: `
            <main (click)="fnA()">
              @defer (on viewport; hydrate on interaction) {
                <div (click)="fnA()">
                  Main defer block rendered!
                  @if (visible) {
                    Defer events work!
                  }
                  <div id="outer-trigger" (mouseover)="showMessage()"></div>
                  @defer (on viewport; hydrate on viewport) {
                    <p (click)="fnA()">Nested defer block</p>
                    <dep-b />
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                </div>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          items = [1, 2, 3];
          visible = false;
          fnA() {}
          showMessage() {
            this.visible = true;
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration(), withEventReplay()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":2,"s":2,"t":[2],"p":"d0"}}',
        );
      });

      it('should not include parent id in serialized data for top-level `@defer` blocks', async () => {
        @Component({
          selector: 'app',
          template: `
            @defer (on viewport; hydrate on interaction) {
              Hello world!
            } @placeholder {
              <span>Placeholder</span>
            }
          `,
        })
        class SimpleComponent {}

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {
          envProviders: providers,
          hydrationFeatures,
        });

        const ssrContents = getAppContents(html);

        // Assert that the serialized data doesn't contain the "p" field,
        // which contains parent id (which is not needed for top-level blocks).
        expect(ssrContents).toContain('"__nghDeferData__":{"d0":{"r":1,"s":2}}}');
      });
    });

    describe('basic hydration behavior', () => {
      it('should SSR and hydrate top-level `@defer` blocks', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (on viewport; hydrate on interaction) {
                <article (click)="fnA()">
                  Main defer block rendered!
                  @if (visible) {
                    Defer events work!
                  }
                  <aside id="outer-trigger" (mouseover)="showMessage()"></aside>
                  @defer (on viewport; hydrate on interaction) {
                    <p (click)="fnA()">Nested defer block</p>
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                </article>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          items = [1, 2, 3];
          visible = false;
          fnA() {}
          showMessage() {
            this.visible = true;
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {
          envProviders: providers,
          hydrationFeatures,
        });

        const ssrContents = getAppContents(html);

        // Assert that we have `jsaction` annotations and
        // defer blocks are triggered and rendered.

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // <div>s inside a defer block have `d0` as a namespace.
        expect(ssrContents).toContain('<article jsaction="click:;keydown:;" ngb="d0');
        expect(ssrContents).toContain('<aside id="outer-trigger" jsaction="mouseover:;" ngb="d0');
        // <p> is inside a nested defer block -> different namespace.
        expect(ssrContents).toContain('<p jsaction="click:;keydown:;" ngb="d1');
        // There is an extra annotation in the TransferState data.
        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"}}',
        );
        // Outer defer block is rendered.
        expect(ssrContents).toContain('Main defer block rendered');
        // Inner defer block is rendered as well.
        expect(ssrContents).toContain('Nested defer block');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();
        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        appRef.tick();
        await appRef.whenStable();

        const appHostNode = compRef.location.nativeElement;

        // At this point an eager part of an app is hydrated,
        // but defer blocks are still in dehydrated state.

        // <main> no longer has `jsaction` attribute.
        expect(appHostNode.outerHTML).toContain('<main>');

        // Elements from @defer blocks still have `jsaction` annotations,
        // since they were not triggered yet.
        expect(appHostNode.outerHTML).toContain('<article jsaction="click:;keydown:;" ngb="d0');
        expect(appHostNode.outerHTML).toContain(
          '<aside id="outer-trigger" jsaction="mouseover:;" ngb="d0',
        );
        expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1');

        // Emit an event inside of a defer block, which should result
        // in triggering the defer block (start loading deps, etc) and
        // subsequent hydration.
        const inner = doc.getElementById('outer-trigger')!;
        const clickEvent2 = new CustomEvent('mouseover', {bubbles: true});
        inner.dispatchEvent(clickEvent2);
        await allPendingDynamicImports();

        appRef.tick();

        // An event was replayed after hydration, which resulted in
        // an `@if` block becoming active and its inner content got
        // rendered/
        expect(appHostNode.outerHTML).toContain('Defer events work');

        // All outer defer block elements no longer have `jsaction` annotations.
        expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;" ngb="d0');
        expect(appHostNode.outerHTML).not.toContain(
          '<div id="outer-trigger" jsaction="mouseover:;" ngb="d0',
        );

        // Inner defer block was not triggered, thus it retains `jsaction` attributes.
        expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1');
      });

      it('should SSR and hydrate nested `@defer` blocks', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (on viewport; hydrate on interaction) {
                <div (click)="fnA()">
                  Main defer block rendered!
                  @if (visible) {
                    Defer events work!
                  }
                  <div id="outer-trigger" (mouseover)="showMessage()"></div>
                  @defer (on viewport; hydrate on interaction) {
                    <p (click)="showMessage()">Nested defer block</p>
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                </div>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          items = [1, 2, 3];
          visible = false;
          fnA() {}
          showMessage() {
            this.visible = true;
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // Assert that we have `jsaction` annotations and
        // defer blocks are triggered and rendered.

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // <div>s inside a defer block have `d0` as a namespace.
        expect(ssrContents).toContain('<div jsaction="click:;keydown:;" ngb="d0"');
        expect(ssrContents).toContain('<div id="outer-trigger" jsaction="mouseover:;" ngb="d0"');
        // <p> is inside a nested defer block -> different namespace.
        expect(ssrContents).toContain('<p jsaction="click:;keydown:;" ngb="d1');
        // There is an extra annotation in the TransferState data.
        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"}}',
        );
        // Outer defer block is rendered.
        expect(ssrContents).toContain('Main defer block rendered');
        // Inner defer block is rendered as well.
        expect(ssrContents).toContain('Nested defer block');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();

        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        appRef.tick();
        await appRef.whenStable();

        const appHostNode = compRef.location.nativeElement;

        // At this point an eager part of an app is hydrated,
        // but defer blocks are still in dehydrated state.

        // <main> no longer has `jsaction` attribute.
        expect(appHostNode.outerHTML).toContain('<main>');

        // Elements from @defer blocks still have `jsaction` annotations,
        // since they were not triggered yet.
        expect(appHostNode.outerHTML).toContain('<div jsaction="click:;keydown:;" ngb="d0"');
        expect(appHostNode.outerHTML).toContain(
          '<div id="outer-trigger" jsaction="mouseover:;" ngb="d0',
        );
        expect(appHostNode.outerHTML).toContain('<p jsaction="click:;keydown:;" ngb="d1"');

        // Emit an event inside of a defer block, which should result
        // in triggering the defer block (start loading deps, etc) and
        // subsequent hydration.
        const inner = doc.body.querySelector('p')!;
        const clickEvent = new CustomEvent('click', {bubbles: true});
        inner.dispatchEvent(clickEvent);

        await allPendingDynamicImports();

        appRef.tick();

        // An event was replayed after hydration, which resulted in
        // an `@if` block becoming active and its inner content got
        // rendered/
        expect(appHostNode.outerHTML).toContain('Defer events work');

        // Since inner `@defer` block was triggered, all parent blocks
        // were hydrated as well, so all `jsaction` attributes are removed.
        expect(appHostNode.outerHTML).not.toContain('jsaction="');
      });

      it('should SSR and hydrate only defer blocks with hydrate syntax', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (hydrate on interaction) {
                <div (click)="fnA()">
                  Main defer block rendered!
                  @if (visible) {
                    Defer events work!
                  }
                  <div id="outer-trigger" (mouseover)="showMessage()"></div>
                  @defer (on interaction) {
                    <p (click)="showMessage()">Nested defer block</p>
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                </div>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          items = [1, 2, 3];
          visible = false;
          fnA() {}
          showMessage() {
            this.visible = true;
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}, withDebugConsole()];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // Assert that we have `jsaction` annotations and
        // defer blocks are triggered and rendered.

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // <div>s inside a defer block have `d0` as a namespace.
        expect(ssrContents).toContain('<div jsaction="click:;keydown:;" ngb="d0"');
        expect(ssrContents).toContain('<div id="outer-trigger" jsaction="mouseover:;" ngb="d0"');
        // <p> is inside a nested defer block -> different namespace.
        // expect(ssrContents).toContain('<p jsaction="click:;" ngb="d1');
        // There is an extra annotation in the TransferState data.
        expect(ssrContents).toContain('"__nghDeferData__":{"d0":{"r":1,"s":2}}');
        // Outer defer block is rendered.
        expect(ssrContents).toContain('Main defer block rendered');
        // Inner defer block should only display placeholder.
        expect(ssrContents).toContain('Inner block placeholder');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();

        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        appRef.tick();
        await appRef.whenStable();

        verifyHasLog(
          appRef,
          'Angular hydrated 1 component(s) and 8 node(s), 0 component(s) were skipped. 1 defer block(s) were configured to use incremental hydration.',
        );

        const appHostNode = compRef.location.nativeElement;

        // At this point an eager part of an app is hydrated,
        // but defer blocks are still in dehydrated state.

        // <main> no longer has `jsaction` attribute.
        expect(appHostNode.outerHTML).toContain('<main>');

        // Elements from @defer blocks still have `jsaction` annotations,
        // since they were not triggered yet.
        expect(appHostNode.outerHTML).toContain('<div jsaction="click:;keydown:;" ngb="d0"');
        expect(appHostNode.outerHTML).toContain(
          '<div id="outer-trigger" jsaction="mouseover:;" ngb="d0',
        );
        // expect(appHostNode.outerHTML).toContain('<p jsaction="click:;" ngb="d1"');

        // Emit an event inside of a defer block, which should result
        // in triggering the defer block (start loading deps, etc) and
        // subsequent hydration.
        const inner = doc.getElementById('outer-trigger')!;
        const clickEvent2 = new CustomEvent('mouseover', {bubbles: true});
        inner.dispatchEvent(clickEvent2);
        await allPendingDynamicImports();

        appRef.tick();

        const innerParagraph = doc.body.querySelector('p')!;
        expect(innerParagraph).toBeUndefined();

        // An event was replayed after hydration, which resulted in
        // an `@if` block becoming active and its inner content got
        // rendered/
        expect(appHostNode.outerHTML).toContain('Defer events work');
        expect(appHostNode.outerHTML).toContain('Inner block placeholder');

        // Since inner `@defer` block was triggered, all parent blocks
        // were hydrated as well, so all `jsaction` attributes are removed.
        expect(appHostNode.outerHTML).not.toContain('jsaction="');
      });
    });

    describe('transfer state for nested defer blocks', () => {
      it('should have correct transfer state data for 2-level nested defer blocks', async () => {
        @Component({
          selector: 'app',
          template: `
            @defer (on viewport; hydrate on interaction) {
              <div>
                Level 1
                @defer (on viewport; hydrate on interaction) {
                  <div>Level 2</div>
                } @placeholder {
                  <span>Level 2 placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Level 1 placeholder</span>
            }
          `,
        })
        class SimpleComponent {}

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // Check that levels are rendered
        expect(ssrContents).toContain('Level 1');
        expect(ssrContents).toContain('Level 2');

        // Check the transfer state data
        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"}}',
        );
      });

      it('should have correct transfer state data for 4-level nested defer blocks', async () => {
        @Component({
          selector: 'app',
          template: `
            @defer (on viewport; hydrate on interaction) {
              <div>
                Level 1
                @defer (on viewport; hydrate on interaction) {
                  <div>
                    Level 2
                    @defer (on viewport; hydrate on interaction) {
                      <div>
                        Level 3
                        @defer (on viewport; hydrate on interaction) {
                          <div>Level 4</div>
                        } @placeholder {
                          <span>Level 4 placeholder</span>
                        }
                      </div>
                    } @placeholder {
                      <span>Level 3 placeholder</span>
                    }
                  </div>
                } @placeholder {
                  <span>Level 2 placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Level 1 placeholder</span>
            }
          `,
        })
        class SimpleComponent {}

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // Check that all levels are rendered
        expect(ssrContents).toContain('Level 1');
        expect(ssrContents).toContain('Level 2');
        expect(ssrContents).toContain('Level 3');
        expect(ssrContents).toContain('Level 4');

        // Check the transfer state data
        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"p":"d0"},"d2":{"r":1,"s":2,"p":"d1"},"d3":{"r":1,"s":2,"p":"d2"}}',
        );
      });

      it('should have correct transfer state data for nested defer blocks with different triggers', async () => {
        @Component({
          selector: 'app',
          template: `
            @defer (on viewport; hydrate on interaction) {
              <div>
                Level 1
                @defer (on viewport; hydrate on viewport) {
                  <div>Level 2</div>
                } @placeholder {
                  <span>Level 2 placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Level 1 placeholder</span>
            }
          `,
        })
        class SimpleComponent {}

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // Check that levels are rendered
        expect(ssrContents).toContain('Level 1');
        expect(ssrContents).toContain('Level 2');

        // Check the transfer state data with trigger array
        expect(ssrContents).toContain(
          '"__nghDeferData__":{"d0":{"r":1,"s":2},"d1":{"r":1,"s":2,"t":[2],"p":"d0"}}',
        );
      });
    });

    describe('triggers', () => {
      describe('hydrate on interaction', () => {
        it('click', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (on viewport; hydrate on interaction) {
                  <article>defer block rendered!</article>
                  <span id="test" (click)="fnB()">{{ value() }}</span>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article jsaction="click:;keydown:;"');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          appRef.tick();
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain('<article jsaction="click:;keydown:;"');

          // Emit an event inside of a defer block, which should result
          // in triggering the defer block (start loading deps, etc) and
          // subsequent hydration.
          const article = doc.getElementsByTagName('article')![0];
          const clickEvent = new CustomEvent('click', {bubbles: true});
          article.dispatchEvent(clickEvent);
          await allPendingDynamicImports();

          appRef.tick();
          expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;keydown:;"');
        });

        it('keydown', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (on viewport; hydrate on interaction) {
                  <article>
                    defer block rendered!
                    <span id="test" (click)="fnB()">{{ value() }}</span>
                  </article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article jsaction="click:;keydown:;"');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          appRef.tick();
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain('<article jsaction="click:;keydown:;"');

          // Emit an event inside of a defer block, which should result
          // in triggering the defer block (start loading deps, etc) and
          // subsequent hydration.
          const article = doc.getElementsByTagName('article')![0];
          const keydownEvent = new KeyboardEvent('keydown');
          article.dispatchEvent(keydownEvent);
          await allPendingDynamicImports();

          appRef.tick();

          expect(appHostNode.outerHTML).not.toContain('<div jsaction="click:;keydown:;"');
        });
      });

      describe('hydrate on hover', () => {
        it('mouseover', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (hydrate on hover) {
                  <article>
                    defer block rendered!
                    <span id="test" (click)="fnB()">{{ value() }}</span>
                  </article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article jsaction="mouseenter:;mouseover:;focusin:;"');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          appRef.tick();
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain(
            '<article jsaction="mouseenter:;mouseover:;focusin:;"',
          );

          // Emit an event inside of a defer block, which should result
          // in triggering the defer block (start loading deps, etc) and
          // subsequent hydration.
          const article = doc.getElementsByTagName('article')![0];
          const hoverEvent = new CustomEvent('mouseover', {bubbles: true});
          article.dispatchEvent(hoverEvent);
          await allPendingDynamicImports();

          appRef.tick();

          expect(appHostNode.outerHTML).not.toContain(
            '<div jsaction="mouseenter:;mouseover:;focusin:;"',
          );
        });

        it('focusin', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (hydrate on hover) {
                  <article>
                    defer block rendered!
                    <span id="test" (click)="fnB()">{{ value() }}</span>
                  </article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article jsaction="mouseenter:;mouseover:;focusin:;"');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          appRef.tick();
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain(
            '<article jsaction="mouseenter:;mouseover:;focusin:;"',
          );

          // Emit an event inside of a defer block, which should result
          // in triggering the defer block (start loading deps, etc) and
          // subsequent hydration.
          const article = doc.getElementsByTagName('article')![0];
          const focusEvent = new CustomEvent('focusin', {bubbles: true});
          article.dispatchEvent(focusEvent);
          await allPendingDynamicImports();

          appRef.tick();

          expect(appHostNode.outerHTML).not.toContain(
            '<div jsaction="mouseenter:;mouseover:;focusin:;"',
          );
        });
      });

      describe('viewport', () => {
        let activeObservers: MockIntersectionObserver[] = [];
        let nativeIntersectionObserver: typeof IntersectionObserver;

        beforeEach(() => {
          nativeIntersectionObserver = globalThis.IntersectionObserver;
          globalThis.IntersectionObserver = MockIntersectionObserver;
        });

        afterEach(() => {
          globalThis.IntersectionObserver = nativeIntersectionObserver;
          activeObservers = [];
        });

        /**
         * Mocked out implementation of the native IntersectionObserver API. We need to
         * mock it out for tests, because it's unsupported in Domino and we can't trigger
         * it reliably in the browser.
         */
        class MockIntersectionObserver implements IntersectionObserver {
          root = null;
          rootMargin = null!;
          thresholds = null!;
          scrollMargin = '';

          observedElements = new Set<Element>();
          private elementsInView = new Set<Element>();

          constructor(
            private callback: IntersectionObserverCallback,
            readonly options: IntersectionObserverInit | null = null,
          ) {
            activeObservers.push(this);
          }

          static invokeCallbacksForElement(element: Element, isInView: boolean) {
            for (const observer of activeObservers) {
              const elements = observer.elementsInView;
              const wasInView = elements.has(element);

              if (isInView) {
                elements.add(element);
              } else {
                elements.delete(element);
              }

              observer.invokeCallback();

              if (wasInView) {
                elements.add(element);
              } else {
                elements.delete(element);
              }
            }
          }

          private invokeCallback() {
            for (const el of this.observedElements) {
              this.callback(
                [
                  {
                    target: el,
                    isIntersecting: this.elementsInView.has(el),

                    // Unsupported properties.
                    boundingClientRect: null!,
                    intersectionRatio: null!,
                    intersectionRect: null!,
                    rootBounds: null,
                    time: null!,
                  },
                ],
                this,
              );
            }
          }

          observe(element: Element) {
            this.observedElements.add(element);
            // Native observers fire their callback as soon as an
            // element is observed so we try to mimic it here.
            this.invokeCallback();
          }

          unobserve(element: Element) {
            this.observedElements.delete(element);
          }

          disconnect() {
            this.observedElements.clear();
            this.elementsInView.clear();
          }

          takeRecords(): IntersectionObserverEntry[] {
            throw new Error('Not supported');
          }
        }

        it('viewport', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (hydrate on viewport) {
                  <article>
                    defer block rendered!
                    <span id="test" (click)="fnB()">{{ value() }}</span>
                  </article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<!--ngh=d0-->');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          appRef.tick();
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;
          expect(appHostNode.outerHTML).toContain(
            '<span id="test" jsaction="click:;" ngb="d0">start</span>',
          );

          const article: HTMLElement = document.getElementsByTagName('article')[0];
          MockIntersectionObserver.invokeCallbacksForElement(article, false);

          appRef.tick();

          const testElement = doc.getElementById('test')!;
          const clickEvent = new CustomEvent('click');
          testElement.dispatchEvent(clickEvent);

          appRef.tick();

          expect(appHostNode.outerHTML).toContain(
            '<span id="test" jsaction="click:;" ngb="d0">start</span>',
          );

          MockIntersectionObserver.invokeCallbacksForElement(article, true);

          await allPendingDynamicImports();

          const clickEvent2 = new CustomEvent('click');
          testElement.dispatchEvent(clickEvent2);

          appRef.tick();

          expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
        });

        it('should create IntersectionObserver with the options from the `hydrate on viewport` trigger', async () => {
          @Component({
            selector: 'app',
            template: `
              <main>
                @defer (hydrate on viewport({rootMargin: '123px', threshold: 0.5})) {
                  <article>defer block rendered!</article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {}

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          expect(ssrContents).toContain(
            '"__nghDeferData__":{"d0":{"r":1,"s":2,"t":[{"trigger":2,"intersectionObserverOptions":{"rootMargin":"123px","threshold":0.5}}]}}',
          );

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          appRef.tick();
          await appRef.whenStable();

          expect(activeObservers.length).toBe(1);
          expect(activeObservers[0].options).toEqual({rootMargin: '123px', threshold: 0.5});
        });
      });

      it('immediate', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (hydrate on immediate) {
                <article>
                  defer block rendered!
                  <span id="test" (click)="fnB()">{{ value() }}</span>
                </article>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          value = signal('start');
          fnA() {}
          fnB() {
            this.value.set('end');
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // Outer defer block is rendered.
        expect(ssrContents).toContain('defer block rendered');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();
        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        appRef.tick();
        await appRef.whenStable();
        appRef.tick();

        const appHostNode = compRef.location.nativeElement;
        expect(appHostNode.outerHTML).toContain('<span id="test">start</span>');

        const testElement = doc.getElementById('test')!;
        const clickEvent2 = new CustomEvent('click');
        testElement.dispatchEvent(clickEvent2);

        appRef.tick();

        expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
      });

      describe('idle', () => {
        /**
         * Sets up interceptors for when an idle callback is requested
         * and when it's cancelled. This is needed to keep track of calls
         * made to `requestIdleCallback` and `cancelIdleCallback` APIs.
         */
        let id = 0;
        let idleCallbacksRequested: number;
        let idleCallbacksInvoked: number;
        let idleCallbacksCancelled: number;
        const onIdleCallbackQueue: Map<number, IdleRequestCallback> = new Map();

        function resetCounters() {
          idleCallbacksRequested = 0;
          idleCallbacksInvoked = 0;
          idleCallbacksCancelled = 0;
        }
        resetCounters();

        let nativeRequestIdleCallback: (
          callback: IdleRequestCallback,
          options?: IdleRequestOptions,
        ) => number;
        let nativeCancelIdleCallback: (id: number) => void;

        const mockRequestIdleCallback = (
          callback: IdleRequestCallback,
          options?: IdleRequestOptions,
        ): number => {
          onIdleCallbackQueue.set(id, callback);
          expect(idleCallbacksRequested).toBe(0);
          idleCallbacksRequested++;
          return id++;
        };

        const mockCancelIdleCallback = (id: number) => {
          onIdleCallbackQueue.delete(id);
          idleCallbacksRequested--;
          idleCallbacksCancelled++;
        };

        const triggerIdleCallbacks = () => {
          for (const [_, callback] of onIdleCallbackQueue) {
            idleCallbacksInvoked++;
            callback(null!);
          }
          onIdleCallbackQueue.clear();
        };

        beforeEach(() => {
          nativeRequestIdleCallback = globalThis.requestIdleCallback;
          nativeCancelIdleCallback = globalThis.cancelIdleCallback;
          globalThis.requestIdleCallback = mockRequestIdleCallback;
          globalThis.cancelIdleCallback = mockCancelIdleCallback;
          resetCounters();
        });

        afterEach(() => {
          globalThis.requestIdleCallback = nativeRequestIdleCallback;
          globalThis.cancelIdleCallback = nativeCancelIdleCallback;
          onIdleCallbackQueue.clear();
          resetCounters();
        });

        it('idle', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (hydrate on idle) {
                  <article>
                    defer block rendered!
                    <span id="test" (click)="fnB()">{{ value() }}</span>
                  </article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [{provide: APP_ID, useValue: appId}];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article>');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          appRef.tick();
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain('<article>');

          triggerIdleCallbacks();
          await allPendingDynamicImports();
          appRef.tick();

          expect(appHostNode.outerHTML).toContain('<span id="test">start</span>');

          const testElement = doc.getElementById('test')!;
          const clickEvent2 = new CustomEvent('click');
          testElement.dispatchEvent(clickEvent2);

          appRef.tick();

          expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
        });
      });

      describe('timer', () => {
        class FakeTimerScheduler {
          add(delay: number, callback: VoidFunction) {
            callback();
          }
          remove(callback: VoidFunction) {
            /* noop */
          }
        }

        it('top level timer', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (hydrate on timer(150)) {
                  <article>
                    defer block rendered!
                    <span id="test" (click)="fnB()">{{ value() }}</span>
                  </article>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            fnB() {
              this.value.set('end');
            }
          }

          const appId = 'custom-app-id';
          const providers = [
            {provide: APP_ID, useValue: appId},
            {provide: TimerScheduler, useClass: FakeTimerScheduler},
          ];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article>');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain('<article>');
          await allPendingDynamicImports();

          expect(appHostNode.outerHTML).toContain('<span id="test">start</span>');
        });

        it('nested timer', async () => {
          @Component({
            selector: 'app',
            template: `
              <main (click)="fnA()">
                @defer (on viewport; hydrate on interaction) {
                  <div id="main" (click)="fnA()">
                    defer block rendered!
                    @defer (on viewport; hydrate on timer(150)) {
                      <article>
                        <p id="nested">Nested defer block</p>
                        <span id="test">{{ value() }}</span>
                      </article>
                    } @placeholder {
                      <span>Inner block placeholder</span>
                    }
                  </div>
                } @placeholder {
                  <span>Outer block placeholder</span>
                }
              </main>
            `,
          })
          class SimpleComponent {
            value = signal('start');
            fnA() {}
            constructor() {
              if (!isPlatformServer(inject(PLATFORM_ID))) {
                this.value.set('end');
              }
            }
          }

          const appId = 'custom-app-id';
          const providers = [
            {provide: APP_ID, useValue: appId},
            {provide: TimerScheduler, useClass: FakeTimerScheduler},
          ];
          const hydrationFeatures = () => [withIncrementalHydration()];

          const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
          const ssrContents = getAppContents(html);

          // <main> uses "eager" `custom-app-id` namespace.
          expect(ssrContents).toContain('<main jsaction="click:;');
          // <div>s inside a defer block have `d0` as a namespace.
          expect(ssrContents).toContain('<article>');
          // Outer defer block is rendered.
          expect(ssrContents).toContain('defer block rendered');

          // Internal cleanup before we do server->client transition in this test.
          resetTViewsFor(SimpleComponent);

          ////////////////////////////////
          const doc = getDocument();
          const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
            envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
            hydrationFeatures,
          });
          const compRef = getComponentRef<SimpleComponent>(appRef);
          await appRef.whenStable();

          const appHostNode = compRef.location.nativeElement;

          expect(appHostNode.outerHTML).toContain('<article>');

          await allPendingDynamicImports();

          expect(appHostNode.outerHTML).toContain('<span id="test">end</span>');
        });
      });

      it('when', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (on immediate; hydrate when iSaySo()) {
                <article>
                  defer block rendered!
                  <span id="test" (click)="fnB()">{{ value() }}</span>
                </article>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
              <button id="hydrate-me" (click)="triggerHydration()">Click Here</button>
            </main>
          `,
        })
        class SimpleComponent {
          value = signal('start');
          iSaySo = signal(false);
          fnA() {}
          triggerHydration() {
            this.iSaySo.set(true);
          }
          fnB() {
            this.value.set('end');
          }
          registry = inject(DEHYDRATED_BLOCK_REGISTRY);
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // <div>s inside a defer block have `d0` as a namespace.
        expect(ssrContents).toContain('<article>');
        // Outer defer block is rendered.
        expect(ssrContents).toContain('defer block rendered');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();
        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        const registry = compRef.instance.registry;
        spyOn(registry, 'cleanup').and.callThrough();
        appRef.tick();
        await appRef.whenStable();

        const appHostNode = compRef.location.nativeElement;

        const article = appHostNode.querySelector('article');

        verifyNodeWasNotHydrated(article);

        expect(appHostNode.outerHTML).toContain(
          '<span id="test" jsaction="click:;" ngb="d0">start</span>',
        );
        expect(registry.has('d0')).toBeTruthy();

        const testElement = doc.getElementById('hydrate-me')!;
        const clickEvent = new CustomEvent('click');
        testElement.dispatchEvent(clickEvent);

        await allPendingDynamicImports();
        appRef.tick();

        await appRef.whenStable();

        verifyNodeWasHydrated(article);
        expect(registry.cleanup).toHaveBeenCalledTimes(1);

        expect(registry.has('d0')).toBeFalsy();
        expect(appHostNode.outerHTML).toContain('<span id="test">start</span>');
      });

      it('never', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (hydrate never) {
                <article>defer block rendered!</article>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          value = signal('start');
          fnA() {}
          fnB() {
            this.value.set('end');
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // <div>s inside a defer block have `d0` as a namespace.
        expect(ssrContents).toContain('<article>');
        // Outer defer block is rendered.
        expect(ssrContents).toContain('defer block rendered');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();
        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        appRef.tick();
        await appRef.whenStable();

        const appHostNode = compRef.location.nativeElement;

        expect(appHostNode.outerHTML).toContain('<article>');

        await timeout(500); // wait for timer
        appRef.tick();

        await allPendingDynamicImports();
        appRef.tick();

        expect(appHostNode.outerHTML).not.toContain('Outer block placeholder');
      });

      it('defer triggers should not fire when hydrate never is used', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (on timer(1s); hydrate never) {
                <article>
                  defer block rendered!
                  <span id="test" (click)="fnB()">{{ value() }}</span>
                </article>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          value = signal('start');
          fnA() {}
          fnB() {
            this.value.set('end');
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        // <main> uses "eager" `custom-app-id` namespace.
        expect(ssrContents).toContain('<main jsaction="click:;');
        // <div>s inside a defer block have `d0` as a namespace.
        expect(ssrContents).toContain('<article>');
        // Outer defer block is rendered.
        expect(ssrContents).toContain('defer block rendered');

        // Internal cleanup before we do server->client transition in this test.
        resetTViewsFor(SimpleComponent);

        ////////////////////////////////
        const doc = getDocument();
        const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
          envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
          hydrationFeatures,
        });
        const compRef = getComponentRef<SimpleComponent>(appRef);
        appRef.tick();
        await appRef.whenStable();

        const appHostNode = compRef.location.nativeElement;

        expect(appHostNode.outerHTML).toContain('<article>');

        expect(appHostNode.outerHTML).toContain('>start</span>');

        await timeout(500); // wait for timer
        appRef.tick();

        await allPendingDynamicImports();
        appRef.tick();

        const testElement = doc.getElementById('test')!;
        const clickEvent2 = new CustomEvent('click');
        testElement.dispatchEvent(clickEvent2);

        appRef.tick();

        expect(appHostNode.outerHTML).toContain('>start</span>');
        expect(appHostNode.outerHTML).not.toContain('<span id="test">end</span>');

        expect(appHostNode.outerHTML).not.toContain('Outer block placeholder');
      });

      it('should not annotate jsaction events for events inside a hydrate never block', async () => {
        @Component({
          selector: 'app',
          template: `
            <main (click)="fnA()">
              @defer (on timer(1s); hydrate never) {
                <article>
                  defer block rendered!
                  <span id="test" (click)="fnB()">{{ value() }}</span>
                  @defer (on immediate; hydrate on idle) {
                    <p id="test2" (click)="fnB()">shouldn't be annotated</p>
                  } @placeholder {
                    <p>blah de blah</p>
                  }
                </article>
              } @placeholder {
                <span>Outer block placeholder</span>
              }
              @defer (on timer(1s); hydrate on viewport) {
                <div>
                  viewport section
                  <p (click)="fnA()">has a binding</p>
                </div>
              } @placeholder {
                <span>another placeholder</span>
              }
            </main>
          `,
        })
        class SimpleComponent {
          value = signal('start');
          fnA() {}
          fnB() {
            this.value.set('end');
          }
        }

        const appId = 'custom-app-id';
        const providers = [{provide: APP_ID, useValue: appId}];
        const hydrationFeatures = () => [withIncrementalHydration()];

        const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
        const ssrContents = getAppContents(html);

        expect(ssrContents).not.toContain('<span id="test" jsaction="click:;');
        expect(ssrContents).toContain('<span id="test">start</span>');
        expect(ssrContents).toContain('<p jsaction="click:;" ngb="d1">has a binding</p>');
        expect(ssrContents).not.toContain('<p id="test2" jsaction="click:;');
        expect(ssrContents).toContain('<p id="test2">shouldn\'t be annotated</p>');
      });
    });

    it('should only count and log blocks that were skipped', async () => {
      @Component({
        selector: 'app',
        template: `
          <main (click)="fnA()">
            @defer (on viewport; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                <aside>Main defer block rendered!</aside>
                @defer (on viewport; hydrate on interaction) {
                  <p id="nested">Nested defer block</p>
                } @placeholder {
                  <span>Inner block placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
            @defer (on viewport) {
              <p>This should remain in the registry</p>
            } @placeholder {
              <span>a second placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        fnA() {}
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}, withDebugConsole()];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();

      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      appRef.tick();
      await appRef.whenStable();

      verifyHasLog(
        appRef,
        'Angular hydrated 1 component(s) and 16 node(s), 0 component(s) were skipped. 2 defer block(s) were configured to use incremental hydration.',
      );
    });
  });

  describe('client side navigation', () => {
    beforeEach(() => {
      // This test emulates client-side behavior, set global server mode flag to `false`.
      globalThis['ngServerMode'] = false;

      TestBed.configureTestingModule({
        providers: [
          {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
          provideClientHydration(withIncrementalHydration()),
        ],
      });
    });

    afterEach(() => {
      globalThis['ngServerMode'] = undefined;
    });

    it('should not try to hydrate in CSR only cases', async () => {
      @Component({
        selector: 'app',
        template: `
          @defer (hydrate when true; on interaction) {
            <p>Defer block rendered!</p>
          } @placeholder {
            <span>Outer block placeholder</span>
          }
        `,
      })
      class SimpleComponent {}

      const fixture = TestBed.createComponent(SimpleComponent);
      fixture.detectChanges();

      // Verify that `hydrate when true` doesn't trigger rendering of the main
      // content in client-only use-cases (expecting to see placeholder content).
      expect(fixture.nativeElement.innerHTML).toContain('Outer block placeholder');
    });
  });

  describe('control flow', () => {
    let pollingInterval: ReturnType<typeof setInterval>;

    afterEach(() => {
      if (pollingInterval !== undefined) clearInterval(pollingInterval);
    });

    it('should support hydration for all items in a for loop', async () => {
      @Component({
        selector: 'app',
        template: `
          <main>
            @defer (on interaction; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                <p>Main defer block rendered!</p>
                @for (item of items; track $index) {
                  @defer (on interaction; hydrate on interaction) {
                    <article id="item-{{ item }}">
                      defer block {{ item }} rendered!
                      <span (click)="fnB()">{{ value() }}</span>
                    </article>
                  } @placeholder {
                    <span>Outer block placeholder</span>
                  }
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        value = signal('start');
        items = [1, 2, 3, 4, 5, 6];
        fnA() {}
        fnB() {
          this.value.set('end');
        }
        registry = inject(DEHYDRATED_BLOCK_REGISTRY);
        private readonly doc = inject(DOCUMENT);

        constructor() {
          // TODO: Understand why this is needed to get the full rendering of the HTML
          // Without it, bindings aren't properly rendered in SSR and the test fails.
          // There was no issue with the zone based scheduler.
          const pendingTasks = inject(PendingTasks);
          pendingTasks.run(
            () =>
              new Promise<void>((resolve) => {
                pollingInterval = setInterval(() => {
                  const el = this.doc.getElementById('item-1');
                  if (el && el.textContent?.includes('defer block 1 rendered')) {
                    clearInterval(pollingInterval);
                    resolve();
                  }
                }, 10);

                // Fallback timeout to prevent indefinite hanging
                setTimeout(() => {
                  clearInterval(pollingInterval);
                  resolve();
                }, 1000);
              }),
          );
        }
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      const ssrContents = getAppContents(html);

      // <main> uses "eager" `custom-app-id` namespace.
      // <div>s inside a defer block have `d0` as a namespace.
      expect(ssrContents).toContain('<article id="item-1" jsaction="click:;keydown:;"');
      // Outer defer block is rendered.
      expect(ssrContents).toContain('defer block 1 rendered');

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();
      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      const registry = compRef.instance.registry;
      spyOn(registry, 'cleanup').and.callThrough();
      appRef.tick();
      await appRef.whenStable();

      const appHostNode = compRef.location.nativeElement;

      expect(appHostNode.outerHTML).toContain('<article id="item-1" jsaction="click:;keydown:;"');

      // Emit an event inside of a defer block, which should result
      // in triggering the defer block (start loading deps, etc) and
      // subsequent hydration.
      const article = doc.getElementById('item-1')!;
      const clickEvent = new CustomEvent('click', {bubbles: true});
      article.dispatchEvent(clickEvent);
      await allPendingDynamicImports();

      appRef.tick();
      expect(appHostNode.outerHTML).not.toContain(
        '<article id="item-1" jsaction="click:;keydown:;"',
      );
      expect(appHostNode.outerHTML).not.toContain('<span>Outer block placeholder</span>');
      expect(registry.cleanup).toHaveBeenCalledTimes(1);
    });

    it('should handle hydration and cleanup when if then condition changes', async () => {
      @Component({
        selector: 'app',
        template: `
          <main>
            @defer (on interaction; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                <p>Main defer block rendered!</p>
                @if (isServer) {
                  @defer (on interaction; hydrate on interaction) {
                    <article id="item">nested defer block rendered!</article>
                  } @placeholder {
                    <span>Outer block placeholder</span>
                  }
                } @else {
                  <p>client side</p>
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        value = signal('start');
        isServer = isPlatformServer(inject(PLATFORM_ID));
        fnA() {}
        fnB() {
          this.value.set('end');
        }
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      const ssrContents = getAppContents(html);

      expect(ssrContents).toContain('<article id="item" jsaction="click:;keydown:;"');
      expect(ssrContents).toContain('nested defer block rendered');

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();
      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      appRef.tick();
      await appRef.whenStable();

      const appHostNode = compRef.location.nativeElement;
      expect(appHostNode.outerHTML).toContain('nested defer block rendered');

      const article = doc.getElementById('item')!;
      const clickEvent = new CustomEvent('click', {bubbles: true});
      article.dispatchEvent(clickEvent);
      await allPendingDynamicImports();

      appRef.tick();

      expect(appHostNode.outerHTML).not.toContain('nested defer block rendered');
      expect(appHostNode.outerHTML).toContain('<p>client side</p>');

      // Emit an event inside of a defer block, which should result
      // in triggering the defer block (start loading deps, etc) and
      // subsequent hydration.
      expect(appHostNode.outerHTML).not.toContain('<span>Outer block placeholder</span>');
    });

    it('should render an error block when loading fails and cleanup the original content', async () => {
      @Component({
        selector: 'nested-cmp',
        template: 'Rendering {{ block }} block.',
      })
      class NestedCmp {
        @Input() block!: string;
      }

      @Component({
        selector: 'app',
        imports: [NestedCmp],
        template: `
          <main>
            @defer (on interaction; hydrate on interaction) {
              <article id="item">
                <nested-cmp [block]="'primary'" />
              </article>
            } @placeholder {
              <span>Outer block placeholder</span>
            } @error {
              <p>Failed to load dependencies :(</p>
              <nested-cmp [block]="'error'" />
            }
          </main>
        `,
      })
      class SimpleComponent {
        @ViewChildren(NestedCmp) cmps!: QueryList<NestedCmp>;
        value = signal('start');
        fnA() {}
        fnB() {
          this.value.set('end');
        }
      }

      const deferDepsInterceptor = {
        intercept() {
          return () => [failedDynamicImport()];
        },
      };

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      const ssrContents = getAppContents(html);

      expect(ssrContents).toContain('<article id="item" jsaction="click:;keydown:;"');
      expect(ssrContents).toContain('Rendering primary block');

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();
      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [
          ...providers,
          {provide: PLATFORM_ID, useValue: 'browser'},
          {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
        ],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      appRef.tick();
      await appRef.whenStable();

      const appHostNode = compRef.location.nativeElement;
      expect(appHostNode.outerHTML).toContain('Rendering primary block');

      const article = doc.getElementById('item')!;
      const clickEvent = new CustomEvent('click', {bubbles: true});
      article.dispatchEvent(clickEvent);
      await allPendingDynamicImports();

      appRef.tick();

      expect(appHostNode.outerHTML).not.toContain('Rendering primary block');
      expect(appHostNode.outerHTML).toContain('Rendering error block');
    });
  });

  describe('cleanup', () => {
    it('should cleanup partial hydration blocks appropriately', async () => {
      @Component({
        selector: 'app',
        template: `
          <main (click)="fnA()">
            @defer (on idle; hydrate on interaction) {
              <p id="test">inside defer block</p>
              @if (isServer) {
                <span>Server!</span>
              } @else {
                <span>Client!</span>
              }
            } @loading {
              <span>Loading...</span>
            } @placeholder {
              <p>Placeholder!</p>
            }
          </main>
        `,
      })
      class SimpleComponent {
        fnA() {}
        isServer = isPlatformServer(inject(PLATFORM_ID));
        registry = inject(DEHYDRATED_BLOCK_REGISTRY);
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      const ssrContents = getAppContents(html);

      // <main> uses "eager" `custom-app-id` namespace.
      expect(ssrContents).toContain('<main jsaction="click:;');
      // <div>s inside a defer block have `d0` as a namespace.
      expect(ssrContents).toContain(
        '<p id="test" jsaction="click:;keydown:;" ngb="d0">inside defer block</p>',
      );
      // Outer defer block is rendered.
      expect(ssrContents).toContain('<span jsaction="click:;keydown:;" ngb="d0">Server!</span>');

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();
      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      const registry = compRef.instance.registry;
      spyOn(registry, 'cleanup').and.callThrough();

      appRef.tick();
      await appRef.whenStable();
      const appHostNode = compRef.location.nativeElement;

      expect(appHostNode.outerHTML).toContain(
        '<p id="test" jsaction="click:;keydown:;" ngb="d0">inside defer block</p>',
      );

      expect(appHostNode.outerHTML).toContain(
        '<span jsaction="click:;keydown:;" ngb="d0">Server!</span>',
      );

      const testElement = doc.getElementById('test')!;
      const clickEvent = new CustomEvent('click', {bubbles: true});
      testElement.dispatchEvent(clickEvent);

      await allPendingDynamicImports();

      appRef.tick();
      expect(appHostNode.outerHTML).toContain('<span>Client!</span>');
      expect(appHostNode.outerHTML).not.toContain('>Server!</span>');
      expect(registry.cleanup).toHaveBeenCalledTimes(1);
    });

    it('should clear registry of blocks as they are hydrated', async () => {
      @Component({
        selector: 'app',
        template: `
          <main (click)="fnA()">
            @defer (on viewport; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                Main defer block rendered!
                @defer (on viewport; hydrate on interaction) {
                  <p id="nested">Nested defer block</p>
                } @placeholder {
                  <span>Inner block placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        fnA() {}

        registry = inject(DEHYDRATED_BLOCK_REGISTRY);
        jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
        contract = inject(JSACTION_EVENT_CONTRACT);
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();

      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      appRef.tick();
      await appRef.whenStable();

      const registry = compRef.instance.registry;
      const jsActionMap = compRef.instance.jsActionMap;
      const contract = compRef.instance.contract;
      spyOn(contract.instance!, 'cleanUp').and.callThrough();
      spyOn(registry, 'cleanup').and.callThrough();

      expect(registry.size).toBe(1);
      expect(jsActionMap.size).toBe(2);
      expect(registry.has('d0')).toBeTruthy();

      const mainBlock = doc.getElementById('main')!;
      const clickEvent = new CustomEvent('click', {bubbles: true});
      mainBlock.dispatchEvent(clickEvent);

      await allPendingDynamicImports();

      expect(registry.size).toBe(1);
      expect(registry.has('d0')).toBeFalsy();
      expect(jsActionMap.size).toBe(1);
      expect(registry.cleanup).toHaveBeenCalledTimes(1);

      const nested = doc.getElementById('nested')!;
      const clickEvent2 = new CustomEvent('click', {bubbles: true});
      nested.dispatchEvent(clickEvent2);
      await allPendingDynamicImports();
      appRef.tick();

      expect(registry.size).toBe(0);
      expect(jsActionMap.size).toBe(0);
      expect(contract.instance!.cleanUp).toHaveBeenCalled();
      expect(registry.cleanup).toHaveBeenCalledTimes(2);
    });

    it('should clear registry of multiple blocks if they are hydrated in one go', async () => {
      @Component({
        selector: 'app',
        template: `
          <main (click)="fnA()">
            @defer (on viewport; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                Main defer block rendered!
                @defer (on viewport; hydrate on interaction) {
                  <p id="nested">Nested defer block</p>
                } @placeholder {
                  <span>Inner block placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        fnA() {}

        registry = inject(DEHYDRATED_BLOCK_REGISTRY);
        jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
        contract = inject(JSACTION_EVENT_CONTRACT);
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();

      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      appRef.tick();
      await appRef.whenStable();

      const registry = compRef.instance.registry;
      const jsActionMap = compRef.instance.jsActionMap;
      const contract = compRef.instance.contract;
      spyOn(contract.instance!, 'cleanUp').and.callThrough();

      expect(registry.size).toBe(1);
      expect(jsActionMap.size).toBe(2);
      expect(registry.has('d0')).toBeTruthy();

      const nested = doc.getElementById('nested')!;
      const clickEvent2 = new CustomEvent('click', {bubbles: true});
      nested.dispatchEvent(clickEvent2);
      await allPendingDynamicImports();
      appRef.tick();

      expect(registry.size).toBe(0);
      expect(jsActionMap.size).toBe(0);
      expect(contract.instance!.cleanUp).toHaveBeenCalled();
    });

    it('should clean up only one time per stack of blocks post hydration', async () => {
      @Component({
        selector: 'app',
        template: `
          <main (click)="fnA()">
            @defer (on viewport; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                Main defer block rendered!
                @defer (on viewport; hydrate on interaction) {
                  <p id="nested">Nested defer block</p>
                } @placeholder {
                  <span>Inner block placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        fnA() {}

        registry = inject(DEHYDRATED_BLOCK_REGISTRY);
        jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
        contract = inject(JSACTION_EVENT_CONTRACT);
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();

      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      appRef.tick();
      await appRef.whenStable();

      const registry = compRef.instance.registry;
      const jsActionMap = compRef.instance.jsActionMap;
      const contract = compRef.instance.contract;
      spyOn(contract.instance!, 'cleanUp').and.callThrough();
      spyOn(registry, 'cleanup').and.callThrough();

      expect(registry.size).toBe(1);
      expect(jsActionMap.size).toBe(2);
      expect(registry.has('d0')).toBeTruthy();

      const nested = doc.getElementById('nested')!;
      const clickEvent2 = new CustomEvent('click', {bubbles: true});
      nested.dispatchEvent(clickEvent2);
      await allPendingDynamicImports();
      appRef.tick();

      expect(registry.size).toBe(0);
      expect(jsActionMap.size).toBe(0);
      expect(contract.instance!.cleanUp).toHaveBeenCalled();
      expect(registry.cleanup).toHaveBeenCalledTimes(1);
    });

    it('should leave blocks in registry when not hydrated', async () => {
      @Component({
        selector: 'app',
        template: `
          <main (click)="fnA()">
            @defer (on viewport; hydrate on interaction) {
              <div id="main" (click)="fnA()">
                <aside>Main defer block rendered!</aside>
                @defer (on viewport; hydrate on interaction) {
                  <p id="nested">Nested defer block</p>
                } @placeholder {
                  <span>Inner block placeholder</span>
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
            @defer (on viewport; hydrate on hover) {
              <p>This should remain in the registry</p>
            } @placeholder {
              <span>a second placeholder</span>
            }
          </main>
        `,
      })
      class SimpleComponent {
        fnA() {}

        registry = inject(DEHYDRATED_BLOCK_REGISTRY);
        jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
        contract = inject(JSACTION_EVENT_CONTRACT);
      }

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////
      const doc = getDocument();

      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      appRef.tick();
      await appRef.whenStable();
      const contract = compRef.instance.contract;
      spyOn(contract.instance!, 'cleanUp').and.callThrough();

      const registry = compRef.instance.registry;
      const jsActionMap = compRef.instance.jsActionMap;
      spyOn(registry, 'cleanup').and.callThrough();

      // registry size should be the number of highest level dehydrated defer blocks
      // in this case, 2.
      expect(registry.size).toBe(2);
      // jsactionmap should include all elements that have jsaction on them, in this
      // case, 3, due to the defer block root nodes.
      expect(jsActionMap.size).toBe(3);
      expect(registry.has('d0')).toBeTruthy();

      const nested = doc.getElementById('nested')!;
      const clickEvent2 = new CustomEvent('click', {bubbles: true});
      nested.dispatchEvent(clickEvent2);
      await allPendingDynamicImports();
      appRef.tick();

      expect(registry.size).toBe(1);
      expect(jsActionMap.size).toBe(1);
      expect(registry.has('d2')).toBeTruthy();
      expect(contract.instance!.cleanUp).not.toHaveBeenCalled();
      expect(registry.cleanup).toHaveBeenCalledTimes(1);
    });
  });

  describe('Router', () => {
    it('should trigger event replay after next render', async () => {
      @Component({
        selector: 'deferred',
        template: `<p>Deferred content</p>`,
      })
      class DeferredCmp {}

      @Component({
        selector: 'other',
        template: `<p>OtherCmp content</p>`,
      })
      class OtherCmp {}

      @Component({
        selector: 'home',
        imports: [RouterLink, DeferredCmp],
        template: `
          <main (click)="fnA()">
            @defer (on viewport; hydrate on hover) {
              <div id="main" (click)="fnA()">
                <aside>Main defer block rendered!</aside>
                @if (true) {
                  @defer (on viewport; hydrate on hover) {
                    <deferred />
                    <p id="nested">Nested defer block</p>
                    <a id="route-link" [routerLink]="[path, thing(), stuff()]">Go There</a>
                  } @placeholder {
                    <span>Inner block placeholder</span>
                  }
                }
              </div>
            } @placeholder {
              <span>Outer block placeholder</span>
            }
          </main>
        `,
      })
      class HomeCmp {
        path = 'other';
        thing = signal('thing');
        stuff = signal('stuff');
        fnA() {}
      }

      const routes: Routes = [
        {
          path: '',
          component: HomeCmp,
        },
        {
          path: 'other/thing/stuff',
          component: OtherCmp,
        },
      ];

      @Component({
        selector: 'app',
        imports: [RouterOutlet],
        template: `
          Works!
          <router-outlet />
        `,
      })
      class SimpleComponent {
        location = inject(Location);
      }

      const deferDepsInterceptor = {
        intercept() {
          return () => {
            return [dynamicImportOf(DeferredCmp, 100)];
          };
        },
      };

      const appId = 'custom-app-id';
      const providers = [
        {provide: APP_ID, useValue: appId},
        {provide: PlatformLocation, useClass: MockPlatformLocation},
        {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
        provideRouter(routes),
      ] as unknown as Provider[];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

      resetTViewsFor(SimpleComponent, HomeCmp, DeferredCmp);

      const doc = getDocument();
      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      await appRef.whenStable();
      const appHostNode = compRef.location.nativeElement;
      const location = compRef.instance.location;

      const routeLink = doc.getElementById('route-link')!;
      routeLink.click();
      await appRef.whenStable();
      await allPendingDynamicImports();
      appRef.tick();

      await allPendingDynamicImports();
      await appRef.whenStable();

      expect(location.path()).toBe('/other/thing/stuff');

      expect(appHostNode.outerHTML).toContain('<p>OtherCmp content</p>');
    });

    it('should trigger immediate with a lazy loaded route', async () => {
      @Component({
        selector: 'nested-more',
        template: `
          <div>
            @defer (hydrate on immediate) {
              <button id="click-me" (click)="clickMe()">Click me I'm dehydrated?</button>
              <p id="hydrated">{{ hydrated() }}</p>
            }
          </div>
        `,
      })
      class NestedMoreCmp {
        hydrated = signal('nope');
        constructor() {
          if (!isPlatformServer(inject(PLATFORM_ID))) {
            this.hydrated.set('yup');
          }
        }
      }
      @Component({
        selector: 'nested',
        imports: [NestedMoreCmp],
        template: `
          <div>
            @defer (hydrate on interaction) {
              <nested-more />
            }
          </div>
        `,
      })
      class NestedCmp {}

      @Component({
        selector: 'lazy',
        imports: [NestedCmp],
        template: `
          @defer (hydrate on interaction) {
            <nested />
          }
        `,
      })
      class LazyCmp {}

      const routes: Routes = [
        {
          path: '',
          loadComponent: () => dynamicImportOf(LazyCmp, 50),
        },
      ];

      @Component({
        selector: 'app',
        imports: [RouterOutlet],
        template: `
          Works!
          <router-outlet />
        `,
      })
      class SimpleComponent {
        location = inject(Location);
      }

      const appId = 'custom-app-id';
      const providers = [
        {provide: APP_ID, useValue: appId},
        {provide: PlatformLocation, useClass: MockPlatformLocation},
        provideRouter(routes),
      ] as unknown as Provider[];
      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      const ssrContents = getAppContents(html);

      expect(ssrContents).toContain(
        `<button id="click-me" jsaction="click:;" ngb="d2">Click me I'm dehydrated?</button>`,
      );
      expect(ssrContents).toContain(`<p id="hydrated">nope</p>`);

      resetTViewsFor(SimpleComponent, LazyCmp);

      const doc = getDocument();
      const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers],
        hydrationFeatures,
      });
      const compRef = getComponentRef<SimpleComponent>(appRef);
      await appRef.whenStable();
      await allPendingDynamicImports();
      const appHostNode = compRef.location.nativeElement;

      expect(appHostNode.outerHTML).toContain(
        `<button id="click-me">Click me I'm dehydrated?</button>`,
      );
      expect(appHostNode.outerHTML).toContain(`<p id="hydrated">yup</p>`);
    });
  });

  describe('misconfiguration', () => {
    it('should log a warning when `withIncrementalHydration()` is missing in SSR setup', async () => {
      @Component({
        selector: 'app',
        template: `
          @defer (hydrate never) {
            <div>Hydrate never block</div>
          }
        `,
      })
      class SimpleComponent {}

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];

      // Empty list, `withIncrementalHydration()` is not included intentionally.
      const hydrationFeatures = () => [];
      const consoleSpy = spyOn(console, 'warn');
      resetIncrementalHydrationEnabledWarnedForTests();

      await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
      expect(consoleSpy).toHaveBeenCalledTimes(1);
      expect(consoleSpy).toHaveBeenCalledWith(jasmine.stringMatching('NG0508'));
    });

    it('should log a warning when `withIncrementalHydration()` is missing in hydration setup', async () => {
      @Component({
        selector: 'app',
        template: `
          @defer (hydrate never) {
            <div>Hydrate never block</div>
          }
        `,
      })
      class SimpleComponent {}

      const appId = 'custom-app-id';
      const providers = [{provide: APP_ID, useValue: appId}];

      const hydrationFeatures = () => [withIncrementalHydration()];

      const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

      // Internal cleanup before we do server->client transition in this test.
      resetTViewsFor(SimpleComponent);

      ////////////////////////////////

      const consoleSpy = spyOn(console, 'warn');
      resetIncrementalHydrationEnabledWarnedForTests();

      const doc = getDocument();
      await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
        envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
        // Empty list, `withIncrementalHydration()` is not included intentionally.
        hydrationFeatures: () => [],
      });

      expect(consoleSpy).toHaveBeenCalledTimes(1);
      expect(consoleSpy).toHaveBeenCalledWith(jasmine.stringMatching('NG0508'));
    });
  });
});
