refactor(platform-browser): log a warning when a custom or a noop ZoneJS is used with hydration (#49944)

Hydration relies on a signal from ZoneJS when it becomes stable inside an application, so that Angular can start serialization process on the server or post-hydration cleanup on the client (to remove DOM nodes that remained unclaimed).

Providing a custom or a "noop" ZoneJS implementation may lead to a different timing of the "stable" event, thus triggering the serialization or the cleanup too early or too late. This is not yet a fully supported configuration.

This commit adds a warning (non-blocking) for those cases.

PR Close #49944
This commit is contained in:
Andrew Kushnir 2023-04-19 18:12:43 -07:00 committed by Dylan Hunn
parent eb5bc95dbf
commit 3bcbfecb78
9 changed files with 188 additions and 4 deletions

View file

@ -0,0 +1,14 @@
@name Hydration with unsupported Zone.js instance.
@category runtime
@shortDescription Hydration was enabled with unsupported Zone.js instance.
@description
This warning means that the hydration was enabled for an application that was configured to use an unsupported version of Zone.js: either a custom or a "noop" one (see more info [here](api/core/BootstrapOptions#ngZone)).
Hydration relies on a signal from Zone.js when it becomes stable inside an application, so that Angular can start the serialization process on the server or post-hydration cleanup on the client (to remove DOM nodes that remained unclaimed).
Providing a custom or a "noop" Zone.js implementation may lead to a different timing of the "stable" event, thus triggering the serialization or the cleanup too early or too late. This is not yet a fully supported configuration.
If you use a custom Zone.js implementation, make sure that the "onStable" event is emitted at the right time and does not result in incorrect application behavior with hydration.
More information about hydration can be found in the [hydration guide](guide/hydration).

View file

@ -65,7 +65,7 @@ After you've followed these steps and have started up your server, load your app
</div>
You can confirm hydration is enabled by opening Developer Tools in your browser and viewing the console. You should see a message that includes hydration-related stats, such as the number of components and nodes hydrated. Note: Angular calculates the stats based on all components rendered on a page, including those that come from third-party libraries.
While running an application in dev mode, you can confirm hydration is enabled by opening the Developer Tools in your browser and viewing the console. You should see a message that includes hydration-related stats, such as the number of components and nodes hydrated. Note: Angular calculates the stats based on all components rendered on a page, including those that come from third-party libraries.
<a id="constraints"></a>
@ -110,6 +110,13 @@ If you choose to set this setting in your tsconfig, we recommend to set it only
</div>
### Custom or Noop Zone.js are not yet supported
Hydration relies on a signal from Zone.js when it becomes stable inside an application, so that Angular can start the serialization process on the server or post-hydration cleanup on the client to remove DOM nodes that remained unclaimed.
Providing a custom or a "noop" Zone.js implementation may lead to a different timing of the "stable" event, thus triggering the serialization or the cleanup too early or too late. This is not yet a fully supported configuration and you may need to adjust the timing of the `onStable` event in the custom Zone.js implementation.
<a id="errors"></a>
## Errors

View file

@ -0,0 +1,15 @@
## API Report File for "angular-srcs"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public
export const enum RuntimeErrorCode {
// (undocumented)
UNSUPPORTED_ZONEJS_INSTANCE = -5000
}
// (No @packageDocumentation comment for this package)
```

View file

@ -6,6 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
/**
* The list of error codes used in runtime code of the `animations` package.
* Reserved error code range: 3000-3999.
*/
export const enum RuntimeErrorCode {
// Invalid values
INVALID_TIMING_VALUE = 3000,

View file

@ -17,6 +17,14 @@ import {ERROR_DETAILS_PAGE_BASE_URL} from './error_details_base_url';
* error codes which have guides, which might leak into runtime code.
*
* Full list of available error guides can be found at https://angular.io/errors.
*
* Error code ranges per package:
* - core (this package): 100-999
* - forms: 1000-1999
* - common: 2000-2999
* - animations: 3000-3999
* - router: 4000-4999
* - platform-browser: 5000-5500
*/
export const enum RuntimeErrorCode {
// Change Detection Errors

View file

@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "api_golden_test_npm_package", "ng_module", "ng_package", "tsec_test")
load("//tools:defaults.bzl", "api_golden_test", "api_golden_test_npm_package", "ng_module", "ng_package", "tsec_test")
package(default_visibility = ["//visibility:public"])
@ -62,6 +62,16 @@ api_golden_test_npm_package(
npm_package = "angular/packages/platform-browser/npm_package",
)
api_golden_test(
name = "platform-browser_errors",
data = [
"//goldens:public-api",
"//packages/platform-browser",
],
entry_point = "angular/packages/platform-browser/src/errors.d.ts",
golden = "angular/goldens/public-api/platform-browser/errors.md",
)
filegroup(
name = "files_for_docgen",
srcs = glob([

View file

@ -0,0 +1,16 @@
/**
* @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
*/
/**
* The list of error codes used in runtime code of the `platform-browser` package.
* Reserved error code range: 5000-5500.
*/
export const enum RuntimeErrorCode {
// Hydration Errors
UNSUPPORTED_ZONEJS_INSTANCE = -5000,
}

View file

@ -7,7 +7,9 @@
*/
import {ɵwithHttpTransferCache as withHttpTransferCache} from '@angular/common/http';
import {EnvironmentProviders, makeEnvironmentProviders, Provider, ɵwithDomHydration as withDomHydration} from '@angular/core';
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, makeEnvironmentProviders, NgZone, Provider, ɵConsole as Console, ɵformatRuntimeError as formatRuntimeError, ɵwithDomHydration as withDomHydration} from '@angular/core';
import {RuntimeErrorCode} from './errors';
/**
* The list of features as an enum to uniquely type each `HydrationFeature`.
@ -91,6 +93,33 @@ export function withNoHttpTransferCache():
return hydrationFeature(HydrationFeatureKind.NoHttpTransferCache);
}
/**
* Returns an `ENVIRONMENT_INITIALIZER` token setup with a function
* that verifies whether compatible ZoneJS was used in an application
* and logs a warning in a console if it's not the case.
*/
function provideZoneJsCompatibilityDetector(): Provider[] {
return [{
provide: ENVIRONMENT_INITIALIZER,
useValue: () => {
const ngZone = inject(NgZone);
// Checking `ngZone instanceof NgZone` would be insufficient here,
// because custom implementations might use NgZone as a base class.
if (ngZone.constructor !== NgZone) {
const console = inject(Console);
const message = formatRuntimeError(
RuntimeErrorCode.UNSUPPORTED_ZONEJS_INSTANCE,
'Angular detected that hydration was enabled for an application ' +
'that uses a custom or a noop Zone.js implementation. ' +
'This is not yet a fully supported configuration.');
// tslint:disable-next-line:no-console
console.warn(message);
}
},
multi: true,
}];
}
/**
* Sets up providers necessary to enable hydration functionality for the application.
* By default, the function enables the recommended set of features for the optimal
@ -142,6 +171,7 @@ export function provideClientHydration(...features: HydrationFeature<HydrationFe
}
return makeEnvironmentProviders([
(typeof ngDevMode !== 'undefined' && ngDevMode) ? provideZoneJsCompatibilityDetector() : [],
(featuresKind.has(HydrationFeatureKind.NoDomReuseFeature) ? [] : withDomHydration()),
(featuresKind.has(HydrationFeatureKind.NoHttpTransferCache) ? [] : withHttpTransferCache()),
providers,

View file

@ -15,6 +15,7 @@ import {Console} from '@angular/core/src/console';
import {InitialRenderPendingTasks} from '@angular/core/src/initial_render_pending_tasks';
import {getComponentDef} from '@angular/core/src/render3/definition';
import {unescapeTransferStateContent} from '@angular/core/src/transfer_state';
import {NoopNgZone} from '@angular/core/src/zone/ng_zone';
import {TestBed} from '@angular/core/testing';
import {bootstrapApplication, HydrationFeature, HydrationFeatureKind, provideClientHydration, withNoDomReuse} from '@angular/platform-browser';
import {provideRouter, RouterOutlet, Routes} from '@angular/router';
@ -587,10 +588,17 @@ describe('platform-server integration', () => {
resetTViewsFor(SimpleComponent, NestedComponent);
const appRef = await hydrate(html, SimpleComponent);
const appRef = await hydrate(html, SimpleComponent, [withDebugConsole()]);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
// Make sure there are no extra logs in case
// default NgZone is setup for an application.
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);
@ -3990,6 +3998,78 @@ describe('platform-server integration', () => {
});
});
describe('unsupported Zone.js config', () => {
it('should log a warning when a noop zone is used', async () => {
@Component({
standalone: true,
selector: 'app',
template: `Hi!`,
})
class SimpleComponent {
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
const appRef = await hydrate(html, SimpleComponent, [
{provide: NgZone, useValue: new NoopNgZone()},
withDebugConsole(),
]);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
verifyHasLog(
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);
});
it('should log a warning when a custom zone is used', async () => {
@Component({
standalone: true,
selector: 'app',
template: `Hi!`,
})
class SimpleComponent {
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
class CustomNgZone extends NgZone {}
const appRef = await hydrate(html, SimpleComponent, [
{provide: NgZone, useValue: new CustomNgZone({})},
withDebugConsole(),
]);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();
verifyHasLog(
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('error handling', () => {
it('should handle text node mismatch', async () => {
@Component({