refactor(platform-server): split zone/zoneless tests.

The Zone tests are a subset of tests that we still using the Zone CD provider.
This commit is contained in:
Matthieu Riegler 2026-02-06 03:54:24 +01:00 committed by Matthew Beck (Berry)
parent 63d8005703
commit 08ea105aa3
5 changed files with 729 additions and 705 deletions

View file

@ -29,12 +29,21 @@ import {performanceMarkFeature} from '../util/performance';
import {NgZone} from '../zone';
import {withEventReplay} from './event_replay';
import {
ChangeDetectionScheduler,
NotificationSource,
} from '../change_detection/scheduling/zoneless_scheduling';
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '../defer/registry';
import {processAndInitTriggers} from '../defer/triggering';
import {DOCUMENT} from '../document';
import {DOC_PAGE_BASE_URL} from '../error_details_base_url';
import {cleanupDehydratedViews} from './cleanup';
import {
enableClaimDehydratedIcuCaseImpl,
enablePrepareI18nBlockForHydrationImpl,
setIsI18nHydrationSupportEnabled,
} from './i18n';
import {gatherDeferBlocksCommentNodes} from './node_lookup_utils';
import {
IS_HYDRATION_DOM_REUSE_ENABLED,
IS_I18N_HYDRATION_ENABLED,
@ -52,11 +61,6 @@ import {
verifySsrContentsIntegrity,
} from './utils';
import {enableFindMatchingDehydratedViewImpl} from './views';
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '../defer/registry';
import {gatherDeferBlocksCommentNodes} from './node_lookup_utils';
import {processAndInitTriggers} from '../defer/triggering';
import {DOCUMENT} from '../document';
import {DOC_PAGE_BASE_URL} from '../error_details_base_url';
/**
* Indicates whether the hydration-related code was added,
@ -277,6 +281,7 @@ export function withDomHydration(): EnvironmentProviders {
{
provide: APP_BOOTSTRAP_LISTENER,
useFactory: () => {
const scheduler = inject(ChangeDetectionScheduler);
if (inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
const appRef = inject(ApplicationRef);
@ -304,6 +309,8 @@ export function withDomHydration(): EnvironmentProviders {
countBlocksSkippedByHydration(appRef.injector);
printHydrationStats(appRef.injector);
}
// We need to schedule the execution of the render hooks because the hydration cleanup alters the DOM.
scheduler.notify(NotificationSource.RenderHook);
});
};
}

View file

@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "angular_jasmine_test", "ts_project")
load("//tools:defaults.bzl", "angular_jasmine_test", "ts_project", "zoneless_jasmine_test")
ts_project(
name = "dom_utils",
@ -65,6 +65,7 @@ ts_project(
],
)
# This target is meant to run with Zone.js as some test cases are shared between Zone & Zoneless.
angular_jasmine_test(
name = "test",
data = [
@ -73,7 +74,7 @@ angular_jasmine_test(
],
)
angular_jasmine_test(
zoneless_jasmine_test(
name = "event_replay_test",
data = [
":event_replay_test_lib",

View file

@ -46,8 +46,8 @@ import {
PLATFORM_ID,
Provider,
provideZoneChangeDetection,
provideZonelessChangeDetection,
QueryList,
signal,
TemplateRef,
ViewChild,
ViewContainerRef,
@ -2821,9 +2821,9 @@ describe('platform-server full application hydration integration', () => {
selector: 'app',
imports: [MyLazyCmp],
template: `
Visible: {{ isVisible }}.
Visible: {{ isVisible() }}.
@defer (when isVisible) {
@defer (when isVisible()) {
<my-lazy-cmp />
} @loading {
Loading...
@ -2835,19 +2835,21 @@ describe('platform-server full application hydration integration', () => {
`,
})
class SimpleComponent {
isVisible = false;
isVisible = signal(false);
pendingTasks = inject(PendingTasks);
ngOnInit() {
const removeTask = this.pendingTasks.add();
setTimeout(() => {
// This changes the triggering condition of the defer block,
// but it should be ignored and the placeholder content should be visible.
this.isVisible = true;
this.isVisible.set(true);
removeTask();
});
}
}
const envProviders = [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
@ -2859,9 +2861,7 @@ describe('platform-server full application hydration integration', () => {
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
@ -4591,103 +4591,129 @@ describe('platform-server full application hydration integration', () => {
expect(clientContents).not.toContain('<b>This is a SERVER-ONLY content</b>');
});
it('should trigger change detection after cleanup (immediate)', async () => {
const observedChildCountLog: number[] = [];
[true, false].forEach((zoneless) => {
it(`should trigger ${zoneless ? 'zoneless' : 'zone'} change detection after cleanup (immediate)`, async () => {
const observedChildCountLog: number[] = [];
@Component({
selector: 'app',
imports: [NgIf],
template: `
<span *ngIf="isServer">This is a SERVER-ONLY content</span>
<span *ngIf="!isServer">This is a CLIENT-ONLY content</span>
`,
})
class SimpleComponent {
isServer = isPlatformServer(inject(PLATFORM_ID));
elementRef = inject(ElementRef);
@Component({
selector: 'app',
imports: [NgIf],
template: `
<span *ngIf="isServer">This is a SERVER-ONLY content</span>
<span *ngIf="!isServer">This is a CLIENT-ONLY content</span>
`,
})
class SimpleComponent {
isServer = isPlatformServer(inject(PLATFORM_ID));
elementRef = inject(ElementRef);
constructor() {
afterEveryRender(() => {
observedChildCountLog.push(this.elementRef.nativeElement.childElementCount);
});
constructor() {
afterEveryRender(() => {
observedChildCountLog.push(this.elementRef.nativeElement.childElementCount);
});
}
}
}
const envProviders = [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
let ssrContents = getAppContents(html);
const envProviders = zoneless ? [] : [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
resetTViewsFor(SimpleComponent);
// Before hydration
expect(observedChildCountLog).toEqual([]);
// Before hydration
expect(observedChildCountLog).toEqual([]);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
await appRef.whenStable();
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
await appRef.whenStable();
// afterRender should be triggered by:
// 1.) Bootstrap
// 2.) Microtask empty event
// 3.) Stabilization + cleanup
expect(observedChildCountLog).toEqual([2, 2, 1]);
});
it('should trigger change detection after cleanup (deferred)', async () => {
const observedChildCountLog: number[] = [];
@Component({
selector: 'app',
imports: [NgIf],
template: `
<span *ngIf="isServer">This is a SERVER-ONLY content</span>
<span *ngIf="!isServer">This is a CLIENT-ONLY content</span>
`,
})
class SimpleComponent {
isServer = isPlatformServer(inject(PLATFORM_ID));
elementRef = inject(ElementRef);
constructor() {
afterEveryRender(() => {
observedChildCountLog.push(this.elementRef.nativeElement.childElementCount);
});
// Create a dummy promise to prevent stabilization
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
if (zoneless) {
// 1.) Bootstrap
// 2.) After cleanup
expect(observedChildCountLog).toEqual([2, 1]);
} else {
// afterRender should be triggered by:
// 1.) Bootstrap
// 2.) Microtask empty event
// 3.) Stabilization + cleanup
expect(observedChildCountLog).toEqual([2, 2, 1]);
}
}
const envProviders = [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
// Before hydration
expect(observedChildCountLog).toEqual([]);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
// afterRender should be triggered by:
// 1.) Bootstrap
// 2.) Microtask empty event
expect(observedChildCountLog).toEqual([2, 2]);
it(`should trigger ${zoneless ? 'zoneless' : 'zone'} change detection after cleanup (deferred)`, async () => {
const observedChildCountLog: number[] = [];
await appRef.whenStable();
@Component({
selector: 'app',
imports: [NgIf],
template: `
<span *ngIf="isServer">This is a SERVER-ONLY content</span>
<span *ngIf="!isServer">This is a CLIENT-ONLY content</span>
`,
})
class SimpleComponent {
isServer = isPlatformServer(inject(PLATFORM_ID));
elementRef = inject(ElementRef);
// afterRender should be triggered by:
// 3.) Microtask empty event
// 4.) Stabilization + cleanup
expect(observedChildCountLog).toEqual([2, 2, 2, 1]);
constructor() {
afterEveryRender(() => {
observedChildCountLog.push(this.elementRef.nativeElement.childElementCount);
});
// Create a dummy promise to prevent stabilization
inject(PendingTasks).run(
async () =>
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
}),
);
}
}
const envProviders = zoneless ? [] : [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
// Before hydration
expect(observedChildCountLog).toEqual([]);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
if (zoneless) {
// afterRender should be triggered by:
// 1.) Bootstrap
expect(observedChildCountLog).toEqual([2]);
} else {
// afterRender should be triggered by:
// 1.) Bootstrap
// 2.) Microtask empty event
expect(observedChildCountLog).toEqual([2, 2]);
}
// Cleanup will happen when stable, it will also schedule another change detection run, so we need to wait for stability again.
await appRef.whenStable();
await appRef.whenStable();
if (zoneless) {
// afterRender should be triggered by:
// 2.) After stablization & cleanup
expect(observedChildCountLog).toEqual([2, 1]);
} else {
// afterRender should be triggered by:
// 3.) Microtask empty event
// 4.) Stabilization + cleanup
expect(observedChildCountLog).toEqual([2, 2, 2, 2, 1]);
}
});
});
});
@ -6896,13 +6922,16 @@ describe('platform-server full application hydration integration', () => {
// and the server: we use it to test the logic to cleanup
// dehydrated views.
isServer = isPlatformServer(inject(PLATFORM_ID));
pendingTasks = inject(PendingTasks);
ngOnInit() {
setTimeout(() => {}, 100);
const remove = this.pendingTasks.add();
setTimeout(() => {
remove();
}, 100);
}
}
const envProviders = [provideZoneChangeDetection() as any];
const html = await ssr(SimpleComponent, {envProviders});
const html = await ssr(SimpleComponent);
let ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
@ -6916,9 +6945,7 @@ describe('platform-server full application hydration integration', () => {
expect(ssrContents).toContain('<b>This is NgSwitch SERVER-ONLY content</b>');
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders,
});
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
@ -7629,46 +7656,6 @@ describe('platform-server full application hydration integration', () => {
});
});
describe('zoneless', () => {
it('should not produce "unsupported configuration" warnings for zoneless mode', async () => {
@Component({
selector: 'app',
template: `
<header>Header</header>
<footer>Footer</footer>
`,
})
class SimpleComponent {}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain(`<app ${NGH_ATTR_NAME}`);
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [
withDebugConsole(),
provideZonelessChangeDetection() as unknown as Provider[],
],
});
const compRef = getComponentRef<SimpleComponent>(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);
@ -7764,7 +7751,6 @@ describe('platform-server full application hydration integration', () => {
class SimpleComponent {}
const envProviders = [
provideZonelessChangeDetection(),
{provide: PlatformLocation, useClass: MockPlatformLocation},
provideRouter(routes),
] as unknown as Provider[];

View file

@ -10,25 +10,38 @@ import {
APP_ID,
ApplicationRef,
Component,
ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY,
destroyPlatform,
ɵgetDocument as getDocument,
inject,
Input,
NgZone,
ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP,
ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT,
PendingTasks,
PLATFORM_ID,
Provider,
QueryList,
ɵresetIncrementalHydrationEnabledWarnedForTests as resetIncrementalHydrationEnabledWarnedForTests,
signal,
ɵTimerScheduler as TimerScheduler,
ViewChildren,
ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR,
ɵDEHYDRATED_BLOCK_REGISTRY as DEHYDRATED_BLOCK_REGISTRY,
ɵJSACTION_BLOCK_ELEMENT_MAP as JSACTION_BLOCK_ELEMENT_MAP,
ɵJSACTION_EVENT_CONTRACT as JSACTION_EVENT_CONTRACT,
ɵgetDocument as getDocument,
ɵresetIncrementalHydrationEnabledWarnedForTests as resetIncrementalHydrationEnabledWarnedForTests,
ɵTimerScheduler as TimerScheduler,
provideZoneChangeDetection,
} from '@angular/core';
import {
isPlatformServer,
Location,
ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID,
PlatformLocation,
} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {TestBed} from '@angular/core/testing';
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router';
import {getAppContents, prepareEnvironmentAndHydrate, resetTViewsFor} from './dom_utils';
import {
clearConsole,
@ -41,20 +54,6 @@ import {
verifyNodeWasNotHydrated,
withDebugConsole,
} from './hydration_utils';
import {
isPlatformServer,
Location,
PlatformLocation,
ɵPLATFORM_BROWSER_ID as PLATFORM_BROWSER_ID,
} from '@angular/common';
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
import {TestBed} from '@angular/core/testing';
import {provideRouter, RouterLink, RouterOutlet, Routes} from '@angular/router';
import {MockPlatformLocation} from '@angular/common/testing';
/**
* Emulates a dynamic import promise.
@ -1410,7 +1409,6 @@ describe('platform-server partial hydration integration', () => {
): number => {
onIdleCallbackQueue.set(id, callback);
expect(idleCallbacksRequested).toBe(0);
expect(NgZone.isInAngularZone()).toBe(true);
idleCallbacksRequested++;
return id++;
};
@ -1469,10 +1467,7 @@ describe('platform-server partial hydration integration', () => {
}
const appId = 'custom-app-id';
const providers = [
{provide: APP_ID, useValue: appId},
provideZoneChangeDetection() as any,
];
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withIncrementalHydration()];
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});
@ -2062,10 +2057,18 @@ describe('platform-server partial hydration integration', () => {
this.value.set('end');
}
registry = inject(DEHYDRATED_BLOCK_REGISTRY);
constructor() {
// TODO: Understand why this is needed to get the full rendering of the HTML
// Without it, bindings aren't properly rendered in SSR and the test fails.
// There was no issue with the zone based scheduler.
const remove = inject(PendingTasks).add();
setTimeout(() => remove(), 10);
}
}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}, provideZoneChangeDetection() as any];
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withIncrementalHydration()];
const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures});

File diff suppressed because it is too large Load diff