angular/packages/platform-server/test/incremental_hydration_spec.ts
Joey Perrott 3a0cfd544d build: migrate to using new jasmine_test (#62086)
Use the new jasmine_test based on rules_js instead of jasmine_node_test from rules_nodejs

PR Close #62086
2025-06-18 08:27:26 +02:00

2761 lines
95 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
APP_ID,
ApplicationRef,
Component,
destroyPlatform,
inject,
Input,
NgZone,
PLATFORM_ID,
Provider,
QueryList,
signal,
ViewChildren,
ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY,
ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP,
ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT,
ɵgetDocument as getDocument,
ɵTimerScheduler as TimerScheduler,
} from '@angular/core';
import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils';
import {
clearConsole,
getComponentRef,
resetNgDevModeCounters,
ssr,
timeout,
verifyHasLog,
verifyNodeWasHydrated,
verifyNodeWasNotHydrated,
withDebugConsole,
} from './hydration_utils';
import {
isPlatformServer,
Location,
PlatformLocation,
ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID,
} from '@angular/common';
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
import {TestBed} from '@angular/core/testing';
import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router';
import {MockPlatformLocation} from '@angular/common/testing';
/**
* 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="');
});
});
/* TODO: tests to add
3. transfer state data is correct for parent / child defer blocks
*/
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!;
observedElements = new Set<Element>();
private elementsInView = new Set<Element>();
constructor(private callback: IntersectionObserverCallback) {
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('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);
expect(NgZone.isInAngularZone()).toBe(true);
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', () => {
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);
}
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',
standalone: true,
template: 'Rendering {{ block }} block.',
})
class NestedCmp {
@Input() block!: string;
}
@Component({
standalone: true,
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 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 throw an error 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 = () => [];
let producedError;
try {
await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
} catch (error: unknown) {
producedError = error;
}
expect((producedError as Error).message).toContain('NG0508');
});
it('should throw an error 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);
////////////////////////////////
let producedError;
try {
const doc = getDocument();
await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [...providers, {provide: PLATFORM_ID, useValue: 'browser'}],
// Empty list, `withIncrementalHydration()` is not included intentionally.
hydrationFeatures: () => [],
});
} catch (error: unknown) {
producedError = error;
}
expect((producedError as Error).message).toContain('NG0508');
});
});
});