but the node was not found',
);
expect(message).toContain('
…
<-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc);
});
});
it('should handle element node mismatch', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
This is an original content
Bold text
Italic text
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const b = this.doc.querySelector('b');
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
b?.parentNode?.replaceChild(span, b);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('
{
const message = (err as Error).message;
expect(message).toContain('During hydration Angular expected but found ');
expect(message).toContain('… <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc);
});
});
it('should handle node mismatch', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
Bold text
This is an original content
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const p = this.doc.querySelector('p');
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
p?.parentNode?.insertBefore(span, p.nextSibling);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected a comment node but found ',
);
expect(message).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
});
});
it(
'should handle node mismatch ' +
'(when it is wrapped into a non-container node)',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `
This is an original content
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const p = this.doc.querySelector('p');
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
p?.parentNode?.insertBefore(span, p.nextSibling);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected a comment node but found ',
);
expect(message).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
});
},
);
it('should handle node mismatch', async () => {
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule],
template: `
Bold text
Italic text
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const b = this.doc.querySelector('b');
const firstCommentNode = b!.nextSibling;
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
firstCommentNode?.parentNode?.insertBefore(span, firstCommentNode.nextSibling);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected a comment node but found ',
);
expect(message).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc);
});
});
it('should handle node mismatches in nested components', async () => {
@Component({
standalone: true,
selector: 'nested-cmp',
imports: [CommonModule],
template: `
Bold text
Italic text
`,
})
class NestedComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const b = this.doc.querySelector('b');
const firstCommentNode = b!.nextSibling;
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
firstCommentNode?.parentNode?.insertBefore(span, firstCommentNode.nextSibling);
}
}
@Component({
standalone: true,
selector: 'app',
imports: [NestedComponent],
template: ``,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected a comment node but found ',
);
expect(message).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
expect(message).toContain('check the "NestedComponent" component');
verifyNodeHasMismatchInfo(doc, 'nested-cmp');
});
});
it('should handle sibling count mismatch', async () => {
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule],
template: `
Bold text
Italic text
Main content
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
this.doc.querySelector('b')?.remove();
this.doc.querySelector('i')?.remove();
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected more sibling nodes to be present',
);
expect(message).toContain('… <-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc);
});
});
it('should handle ViewContainerRef node mismatch', async () => {
@Directive({
standalone: true,
selector: 'b',
})
class SimpleDir {
vcr = inject(ViewContainerRef);
}
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule, SimpleDir],
template: `
Bold text
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const b = this.doc.querySelector('b');
const firstCommentNode = b!.nextSibling;
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
firstCommentNode?.parentNode?.insertBefore(span, firstCommentNode);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected a comment node but found ',
);
expect(message).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc);
});
});
it('should handle a mismatch for a node that goes after a ViewContainerRef node', async () => {
@Directive({
standalone: true,
selector: 'b',
})
class SimpleDir {
vcr = inject(ViewContainerRef);
}
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule, SimpleDir],
template: `
Bold text
Italic text
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterViewInit() {
const b = this.doc.querySelector('b');
const span = this.doc.createElement('span');
span.textContent = 'This is an eeeeevil span causing a problem!';
b?.parentNode?.insertBefore(span, b.nextSibling);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular expected a comment node but found ',
);
expect(message).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('… <-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc);
});
});
it('should handle a case when a node is not found (removed)', async () => {
@Component({
standalone: true,
selector: 'projector-cmp',
template: '',
})
class ProjectorComponent {}
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule, ProjectorComponent],
template: `
Bold text
Italic text
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
ngAfterContentInit() {
this.doc.querySelector('b')?.remove();
this.doc.querySelector('i')?.remove();
}
}
await ssr(SimpleComponent, {
envProviders: [withNoopErrorHandler()],
}).catch((err: unknown) => {
const message = (err as Error).message;
expect(message).toContain(
'During serialization, Angular was unable to find an element in the DOM',
);
expect(message).toContain('… <-- AT THIS LOCATION');
verifyNodeHasMismatchInfo(doc, 'projector-cmp');
});
});
it('should handle a case when a node is not found (detached)', async () => {
@Component({
standalone: true,
selector: 'projector-cmp',
template: '',
})
class ProjectorComponent {}
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule, ProjectorComponent],
template: `
Bold text
`,
})
class SimpleComponent {
private doc = inject(DOCUMENT);
isServer = isPlatformServer(inject(PLATFORM_ID));
constructor() {
if (!this.isServer) {
this.doc.querySelector('b')?.remove();
}
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' {
const message = (err as Error).message;
expect(message).toContain(
'During hydration Angular was unable to locate a node using the "firstChild" path, ' +
'starting from the … node',
);
verifyNodeHasMismatchInfo(doc, 'projector-cmp');
});
});
it('should handle a case when a node is not found (invalid DOM)', async () => {
@Component({
standalone: true,
selector: 'app',
imports: [CommonModule],
template: `
test
`,
})
class SimpleComponent {}
try {
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' <-- AT THIS LOCATION');
expect(message).toContain('check to see if your template has valid HTML structure');
verifyNodeHasMismatchInfo(doc);
}
});
it('should log a warning when there was no hydration info in the TransferState', async () => {
@Component({
standalone: true,
selector: 'app',
template: `Hi!`,
})
class SimpleComponent {}
// Note: SSR *without* hydration logic enabled.
const html = await ssr(SimpleComponent, {enableHydration: false});
const ssrContents = getAppContents(html);
expect(ssrContents).not.toContain('(appRef);
appRef.tick();
verifyHasLog(
appRef,
'NG0505: Angular hydration was requested on the client, ' +
'but there was no serialized information present in the server response',
);
const clientRootNode = compRef.location.nativeElement;
// Make sure that no hydration logic was activated,
// effectively re-rendering from scratch happened and
// all the content inside the host element was
// cleared on the client (as it usually happens in client
// rendering mode).
verifyNoNodesWereClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it(
'should not log a warning when there was no hydration info in the TransferState, ' +
'but a client mode marker is present',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `Hi!`,
})
class SimpleComponent {}
const html = ``;
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [withDebugConsole()],
});
const compRef = getComponentRef(appRef);
appRef.tick();
verifyEmptyConsole(appRef);
const clientRootNode = compRef.location.nativeElement;
expect(clientRootNode.textContent).toContain('Hi!');
},
);
it('should not throw an error when app is destroyed before becoming stable', async () => {
// Spy manually, because we may not be able to retrieve the `DebugConsole`
// after we destroy the application, but we still want to ensure that
// no error is thrown in the console.
const errorSpy = spyOn(console, 'error').and.callThrough();
const logs: string[] = [];
@Component({
standalone: true,
selector: 'app',
template: `Hi!`,
})
class SimpleComponent {
constructor() {
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
if (isBrowser) {
const pendingTasks = inject(PendingTasks);
// Given that, in a real-world scenario, some APIs add a pending
// task and don't remove it until the app is destroyed.
// This could be an HTTP request that contributes to app stability
// and does not respond until the app is destroyed.
pendingTasks.add();
}
}
}
const html = await ssr(SimpleComponent);
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
appRef.isStable.subscribe((isStable) => {
logs.push(`isStable=${isStable}`);
});
// Destroy the application before it becomes stable, because we added
// a task and didn't remove it explicitly.
appRef.destroy();
expect(logs).toEqual([
'isStable=false',
// In the end, the application became stable while being destroyed.
'isStable=true',
]);
// Wait for a microtask so that `whenStableWithTimeout` resolves.
await Promise.resolve();
// Ensure no error has been logged in the console,
// such as "injector has already been destroyed."
expect(errorSpy).not.toHaveBeenCalled();
});
});
describe('@if', () => {
it('should work with `if`s that have different value on the client and on the server', async () => {
@Component({
standalone: true,
selector: 'app',
imports: [NgIf],
template: `
This is NgIf SERVER-ONLY content
This is NgIf CLIENT-ONLY content
@if (isServer) { This is new if SERVER-ONLY content }
@else { This is new if CLIENT-ONLY content }
@if (alwaysTrue) { CLIENT and SERVER content
}
`,
})
class SimpleComponent {
alwaysTrue = true;
// This flag is intentionally different between the client
// and the server: we use it to test the logic to cleanup
// dehydrated views.
isServer = isPlatformServer(inject(PLATFORM_ID));
pendingTasks = inject(PendingTasks);
ngOnInit() {
const remove = this.pendingTasks.add();
setTimeout(() => void remove(), 100);
}
}
const html = await ssr(SimpleComponent);
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('This is new if CLIENT-ONLY content',
);
expect(ssrContents).toContain('This is new if SERVER-ONLY content');
expect(ssrContents).not.toContain('This is NgIf CLIENT-ONLY content');
expect(ssrContents).toContain('This is NgIf SERVER-ONLY content');
// Content that should be rendered on both client and server should also be present.
expect(ssrContents).toContain('CLIENT and SERVER content
');
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
expect(clientRootNode.outerHTML).not.toContain('This is NgIf SERVER-ONLY content');
expect(clientRootNode.outerHTML).not.toContain('This is new if SERVER-ONLY content');
await appRef.whenStable(); // post-hydration cleanup happens here
const clientContents = stripExcessiveSpaces(
stripUtilAttributes(clientRootNode.outerHTML, false),
);
// After the cleanup, we expect to see CLIENT content, but not SERVER.
expect(clientContents).toContain(
'This is new if CLIENT-ONLY content',
);
expect(clientContents).not.toContain('This is new if SERVER-ONLY content');
// Content that should be rendered on both client and server should still be present.
expect(clientContents).toContain('CLIENT and SERVER content
');
const clientOnlyNode1 = clientRootNode.querySelector('i');
const clientOnlyNode2 = clientRootNode.querySelector('#client-only');
verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode1, clientOnlyNode2]);
});
it('should support nested `if`s', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
This is a non-empty block:
@if (true) {
@if (true) {
@if (true) {
Hello world!
}
}
}
Post-container element
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it('should hydrate `else` blocks', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@if (conditionA) {
if block
} @else {
else block
}
`,
})
class SimpleComponent {
conditionA = false;
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
await appRef.whenStable();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
// Verify that we still have expected content rendered.
expect(clientRootNode.innerHTML).toContain(`else block`);
expect(clientRootNode.innerHTML).not.toContain(`if block`);
// Verify that switching `if` condition results
// in an update to the DOM which was previously hydrated.
compRef.instance.conditionA = true;
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.innerHTML).not.toContain(`else block`);
expect(clientRootNode.innerHTML).toContain(`if block`);
});
});
describe('@switch', () => {
it('should work with `switch`es that have different value on the client and on the server', async () => {
@Component({
standalone: true,
selector: 'app',
imports: [NgSwitch, NgSwitchCase],
template: `
This is NgSwitch SERVER-ONLY content
This is NgSwitch CLIENT-ONLY content
@switch (isServer) {
@case (true) { This is a SERVER-ONLY content }
@case (false) { This is a CLIENT-ONLY content }
}
`,
})
class SimpleComponent {
// This flag is intentionally different between the client
// and the server: we use it to test the logic to cleanup
// dehydrated views.
isServer = isPlatformServer(inject(PLATFORM_ID));
ngOnInit() {
setTimeout(() => {}, 100);
}
}
const envProviders = [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('This is a CLIENT-ONLY content');
expect(ssrContents).not.toContain('This is NgSwitch CLIENT-ONLY content');
expect(ssrContents).toContain('This is a SERVER-ONLY content');
expect(ssrContents).toContain('This is NgSwitch SERVER-ONLY content');
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
// NgSwitch had slower cleanup than NgIf
expect(clientRootNode.outerHTML).toContain('This is NgSwitch SERVER-ONLY content');
expect(clientRootNode.outerHTML).not.toContain('This is a SERVER-ONLY content');
expect(clientRootNode.outerHTML).toContain('This is a CLIENT-ONLY content');
expect(clientRootNode.outerHTML).toContain(
'This is NgSwitch CLIENT-ONLY content',
);
await appRef.whenStable();
const clientContents = stripExcessiveSpaces(
stripUtilAttributes(clientRootNode.outerHTML, false),
);
// After the cleanup, we expect to see CLIENT content, but not SERVER.
expect(clientContents).toContain('This is a CLIENT-ONLY content');
expect(clientContents).toContain('This is NgSwitch CLIENT-ONLY content');
expect(clientContents).not.toContain('This is NgSwitch SERVER-ONLY content');
expect(clientContents).not.toContain('This is a SERVER-ONLY content');
const clientOnlyNode1 = clientRootNode.querySelector('#old');
const clientOnlyNode2 = clientRootNode.querySelector('#new');
verifyAllNodesClaimedForHydration(clientRootNode, [clientOnlyNode1, clientOnlyNode2]);
});
it('should cleanup rendered case if none of the cases match on the client', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@switch (label) {
@case ('A') { This is A }
@case ('B') { This is B }
}
`,
})
class SimpleComponent {
// This flag is intentionally different between the client
// and the server: we use it to test the logic to cleanup
// dehydrated views.
label = isPlatformServer(inject(PLATFORM_ID)) ? 'A' : 'Not A';
}
const html = await ssr(SimpleComponent);
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
ssrContents = stripExcessiveSpaces(stripUtilAttributes(ssrContents, false));
expect(ssrContents).toContain('This is A');
const clientRootNode = compRef.location.nativeElement;
await appRef.whenStable();
const clientContents = stripExcessiveSpaces(
stripUtilAttributes(clientRootNode.outerHTML, false),
);
// After the cleanup, we expect that the contents is removed and none
// of the cases are rendered, since they don't match the condition.
expect(clientContents).not.toContain('This is A');
expect(clientContents).not.toContain('This is B');
verifyAllNodesClaimedForHydration(clientRootNode);
});
});
describe('@for', () => {
it('should hydrate for loop content', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
Item #{{ item }}
}
`,
})
class SimpleComponent {
items = [1, 2, 3];
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it('should hydrate @empty block content', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
Item #{{ item }}
} @empty {
This is an "empty" block
}
`,
})
class SimpleComponent {
items = [];
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it(
'should handle a case when @empty block is rendered ' +
'on the server and main content on the client',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
Item #{{ item }}
} @empty {
This is an "empty" block
}
`,
})
class SimpleComponent {
items = isPlatformServer(inject(PLATFORM_ID)) ? [] : [1, 2, 3];
ngOnInit() {
setTimeout(() => {}, 100);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
expect(clientRootNode.innerHTML).toContain('Item #1');
expect(clientRootNode.innerHTML).toContain('Item #2');
expect(clientRootNode.innerHTML).toContain('Item #3');
expect(clientRootNode.innerHTML).not.toContain('This is an "empty" block');
await appRef.whenStable();
// After hydration and post-hydration cleanup,
// expect items to be present, but `@empty` block to be removed.
expect(clientRootNode.innerHTML).toContain('Item #1');
expect(clientRootNode.innerHTML).toContain('Item #2');
expect(clientRootNode.innerHTML).toContain('Item #3');
expect(clientRootNode.innerHTML).not.toContain('This is an "empty" block');
const clientRenderedItems = compRef.location.nativeElement.querySelectorAll('p');
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
},
);
it(
'should handle a case when @empty block is rendered ' +
'on the client and main content on the server',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
Item #{{ item }}
} @empty {
This is an "empty" block
}
`,
})
class SimpleComponent {
items = isPlatformServer(inject(PLATFORM_ID)) ? [1, 2, 3] : [];
ngOnInit() {
setTimeout(() => {}, 100);
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
expect(clientRootNode.innerHTML).not.toContain('Item #1');
expect(clientRootNode.innerHTML).not.toContain('Item #2');
expect(clientRootNode.innerHTML).not.toContain('Item #3');
expect(clientRootNode.innerHTML).toContain('This is an "empty" block');
await appRef.whenStable();
// After hydration and post-hydration cleanup,
// expect an `@empty` block to be present and items to be removed.
expect(clientRootNode.innerHTML).not.toContain('Item #1');
expect(clientRootNode.innerHTML).not.toContain('Item #2');
expect(clientRootNode.innerHTML).not.toContain('Item #3');
expect(clientRootNode.innerHTML).toContain('This is an "empty" block');
const clientRenderedItems = compRef.location.nativeElement.querySelectorAll('div');
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
},
);
it('should handle different number of items rendered on the client and on the server', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@for (item of items; track item) {
Item #{{ item }}
}
`,
})
class SimpleComponent {
// Item '3' is the same, the rest of the items are different.
items = isPlatformServer(inject(PLATFORM_ID)) ? [3, 2, 1] : [3, 4, 5];
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
await appRef.whenStable();
const clientRootNode = compRef.location.nativeElement;
// After hydration and post-hydration cleanup,
// expect items to be present, but `@empty` block to be removed.
expect(clientRootNode.innerHTML).not.toContain('Item #1');
expect(clientRootNode.innerHTML).not.toContain('Item #2');
expect(clientRootNode.innerHTML).toContain('Item #3');
expect(clientRootNode.innerHTML).toContain('Item #4');
expect(clientRootNode.innerHTML).toContain('Item #5');
// Note: we exclude item '3', since it's the same (and at the same location)
// on the server and on the client, so it was hydrated.
const clientRenderedItems = [4, 5].map((id) =>
compRef.location.nativeElement.querySelector(`[id=${id}]`),
);
verifyAllNodesClaimedForHydration(clientRootNode, Array.from(clientRenderedItems));
});
it('should handle a reconciliation with swaps', async () => {
@Component({
selector: 'app',
standalone: true,
template: `
@for(item of items; track item) {
{{ item }}
}
`,
})
class SimpleComponent {
items = ['a', 'b', 'c'];
swap() {
// Reshuffling of the array will result in
// "swap" operations in repeater.
this.items = ['b', 'c', 'a'];
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
await appRef.whenStable();
const root: HTMLElement = compRef.location.nativeElement;
const divs = root.querySelectorAll('div');
expect(divs.length).toBe(3);
compRef.instance.swap();
compRef.changeDetectorRef.detectChanges();
const divsAfterSwap = root.querySelectorAll('div');
expect(divsAfterSwap.length).toBe(3);
});
});
describe('@let', () => {
it('should handle a let declaration', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let greeting = name + '!!!';
Hello, {{greeting}}
`,
})
class SimpleComponent {
name = 'Frodo';
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Hello, Frodo!!!');
compRef.instance.name = 'Bilbo';
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.textContent).toContain('Hello, Bilbo!!!');
});
it('should handle multiple let declarations that depend on each other', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let plusOne = value + 1;
@let plusTwo = plusOne + 1;
@let result = plusTwo + 1;
Result: {{result}}
`,
})
class SimpleComponent {
value = 1;
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Result: 4');
compRef.instance.value = 2;
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.textContent).toContain('Result: 5');
});
it('should handle a let declaration using a pipe that injects ChangeDetectorRef', async () => {
@Pipe({
name: 'double',
standalone: true,
})
class DoublePipe implements PipeTransform {
changeDetectorRef = inject(ChangeDetectorRef);
transform(value: number) {
return value * 2;
}
}
@Component({
standalone: true,
selector: 'app',
imports: [DoublePipe],
template: `
@let result = value | double;
Result: {{result}}
`,
})
class SimpleComponent {
value = 1;
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Result: 2');
compRef.instance.value = 2;
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.textContent).toContain('Result: 4');
});
it('should handle let declarations referenced through multiple levels of views', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@if (true) {
@if (true) {
@let three = two + 1;
The result is {{three}}
}
@let two = one + 1;
}
@let one = value + 1;
`,
})
class SimpleComponent {
value = 0;
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('The result is 3');
compRef.instance.value = 2;
compRef.changeDetectorRef.detectChanges();
expect(clientRootNode.textContent).toContain('The result is 5');
});
it('should handle non-projected let declarations', async () => {
@Component({
selector: 'inner',
template: `
Fallback header
Fallback content
Fallback footer
`,
standalone: true,
})
class InnerComponent {}
@Component({
standalone: true,
selector: 'app',
template: `
@let one = 1;
@let two = one + 1;
`,
imports: [InnerComponent],
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
const expectedContent =
'' +
'Fallback content' +
'';
expect(ssrContents).toContain('${expectedContent}`);
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.innerHTML).toContain(`${expectedContent}`);
});
it('should handle let declaration before and directly inside of an embedded view', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let before = 'before';
@if (true) {
@let inside = 'inside';
{{before}}|{{inside}}
}
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('before|inside');
});
it('should handle let declaration before, directly inside of and after an embedded view', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let before = 'before';
@if (true) {
@let inside = 'inside';
{{inside}}
}
@let after = 'after';
{{before}}|{{after}}
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' before|after');
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('inside before|after');
});
it('should handle let declaration with array inside of an embedded view', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
@let foo = ['foo'];
@if (true) {
{{foo}}
}
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('foo');
});
it('should handle let declaration inside a projected control flow node', async () => {
@Component({
selector: 'test',
template: 'Main: Slot: ',
})
class TestComponent {}
@Component({
selector: 'app',
imports: [TestComponent],
template: `
@let a = 1;
@let b = a + 1;
{{b}}
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(' Slot: 2',
);
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
expect(clientRootNode.textContent).toContain('Main: Slot: 2');
});
});
describe('zoneless', () => {
it('should not produce "unsupported configuration" warnings for zoneless mode', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
// Make sure there are no extra logs in case zoneless mode is enabled.
verifyHasNoLog(
appRef,
'NG05000: Angular detected that hydration was enabled for an application ' +
'that uses a custom or a noop Zone.js implementation.',
);
const clientRootNode = compRef.location.nativeElement;
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
});
describe('Router', () => {
it('should wait for lazy routes before triggering post-hydration cleanup', async () => {
const ngZone = TestBed.inject(NgZone);
@Component({
standalone: true,
selector: 'lazy',
template: `LazyCmp content`,
})
class LazyCmp {}
const routes: Routes = [
{
path: '',
loadComponent: () => {
return ngZone.runOutsideAngular(() => {
return new Promise((resolve) => {
setTimeout(() => resolve(LazyCmp), 100);
});
});
},
},
];
@Component({
standalone: true,
selector: 'app',
imports: [RouterOutlet],
template: `
Works!
`,
})
class SimpleComponent {}
const envProviders = [
{provide: PlatformLocation, useClass: MockPlatformLocation},
provideRouter(routes),
] as unknown as Provider[];
const html = await ssr(SimpleComponent, {envProviders});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`LazyCmp content`);
resetTViewsFor(SimpleComponent, LazyCmp);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
await appRef.whenStable();
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it('should wait for lazy routes before triggering post-hydration cleanup in zoneless mode', async () => {
const ngZone = TestBed.inject(NgZone);
@Component({
standalone: true,
selector: 'lazy',
template: `LazyCmp content`,
})
class LazyCmp {}
const routes: Routes = [
{
path: '',
loadComponent: () => {
return ngZone.runOutsideAngular(() => {
return new Promise((resolve) => {
setTimeout(() => resolve(LazyCmp), 100);
});
});
},
},
];
@Component({
standalone: true,
selector: 'app',
imports: [RouterOutlet],
template: `
Works!
`,
})
class SimpleComponent {}
const envProviders = [
provideZonelessChangeDetection(),
{provide: PlatformLocation, useClass: MockPlatformLocation},
provideRouter(routes),
] as unknown as Provider[];
const html = await ssr(SimpleComponent, {envProviders});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`LazyCmp content`);
resetTViewsFor(SimpleComponent, LazyCmp);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
const compRef = getComponentRef(appRef);
appRef.tick();
const clientRootNode = compRef.location.nativeElement;
await appRef.whenStable();
verifyAllNodesClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
it('should cleanup dehydrated views in routed components that use ViewContainerRef', async () => {
@Component({
standalone: true,
selector: 'cmp-a',
template: `
@if (isServer) {
Server view
} @else {
Client view
}
`,
})
class CmpA {
isServer = isPlatformServer(inject(PLATFORM_ID));
viewContainerRef = inject(ViewContainerRef);
}
const routes: Routes = [
{
path: '',
component: CmpA,
},
];
@Component({
standalone: true,
selector: 'app',
imports: [RouterOutlet],
template: `
`,
})
class SimpleComponent {}
const envProviders = [
{provide: PlatformLocation, useClass: MockPlatformLocation},
provideRouter(routes),
] as unknown as Provider[];
const html = await ssr(SimpleComponent, {envProviders});
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`(appRef);
appRef.tick();
await appRef.whenStable();
const clientRootNode = compRef.location.nativeElement;
// tag is used in a view that is different on a server and
// on a client, so it gets re-created (not hydrated) on a client
const p = clientRootNode.querySelector('p');
verifyAllNodesClaimedForHydration(clientRootNode, [p]);
expect(clientRootNode.innerHTML).not.toContain('Server view');
expect(clientRootNode.innerHTML).toContain('Client view');
});
});
});
});