angular/packages/platform-server/test/incremental_hydration_spec.ts
Jessica Janiuk 30891d8dec refactor(core): Consolidates shouldTrigger* methods down to one (#58833)
This cleans up the triggering code base and consolidates it down to one
function that outlines the logic. This also resolves the `hydrate when`
behavior issue.

fixes: #58709

PR Close #58833
2024-11-27 17:00:06 +01:00

2123 lines
73 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
APP_ID,
Component,
destroyPlatform,
inject,
NgZone,
PLATFORM_ID,
Provider,
signal,
ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
} from '@angular/core';
import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils';
import {
getComponentRef,
ssr,
timeout,
verifyNodeWasHydrated,
verifyNodeWasNotHydrated,
} from './hydration_utils';
import {getDocument} from '@angular/core/src/render3/interfaces/document';
import {isPlatformServer, Location, PlatformLocation} from '@angular/common';
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
import {TestBed} from '@angular/core/testing';
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
import {DEHYDRATED_BLOCK_REGISTRY} from '@angular/core/src/defer/registry';
import {JSACTION_BLOCK_ELEMENT_MAP} from '@angular/core/src/hydration/tokens';
import {JSACTION_EVENT_CONTRACT} from '@angular/core/src/event_delegation_utils';
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);
});
}
/**
* 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('@angular/core/primitives/event-dispatch/contract_bundle_min.js' as string);
});
afterAll(() => {
globalThis.window = originalWindow;
});
afterEach(() => {
destroyPlatform();
window._ejsas = {};
});
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-->');
});
});
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":{"p":null,"r":1,"s":2,"t":[]},"d1":{"p":"d0","r":1,"s":2,"t":[]}}',
);
// 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":{"p":null,"r":1,"s":2,"t":[]},"d1":{"p":"d0","r":1,"s":2,"t":[]}}',
);
// 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}];
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":{"p":null,"r":1,"s":2,"t":[]},"d1":{"p":"d0","r":1,"s":0,"t":null}}',
);
// 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();
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>');
}, 100_000);
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>');
});
});
it('timer', async () => {
@Component({
selector: 'app',
template: `
<main (click)="fnA()">
@defer (hydrate on timer(500)) {
<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>');
await timeout(500); // wait for timer
appRef.tick();
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>');
});
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>');
});
});
describe('client side navigation', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID},
provideClientHydration(withIncrementalHydration()),
],
});
});
it('should not try to hydrate in CSR only cases', async () => {
@Component({
selector: 'app',
template: `
<main (click)="fnA()">
@defer (hydrate when true) {
<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 fixture = TestBed.createComponent(SimpleComponent);
fixture.detectChanges();
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);
});
});
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>');
});
});
});