From a31279fc991eb92851525765a65a114c190e1570 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 19 Apr 2023 18:12:43 -0700 Subject: [PATCH] refactor(platform-browser): log a warning when a custom or a noop ZoneJS is used with hydration 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. --- aio/content/errors/NG5000.md | 14 ++++ aio/content/guide/hydration.md | 9 +- goldens/public-api/platform-browser/errors.md | 15 ++++ packages/animations/browser/src/errors.ts | 4 + packages/core/src/errors.ts | 8 ++ packages/platform-browser/BUILD.bazel | 12 ++- packages/platform-browser/src/errors.ts | 16 ++++ packages/platform-browser/src/hydration.ts | 32 +++++++- .../platform-server/test/hydration_spec.ts | 82 ++++++++++++++++++- 9 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 aio/content/errors/NG5000.md create mode 100644 goldens/public-api/platform-browser/errors.md create mode 100644 packages/platform-browser/src/errors.ts diff --git a/aio/content/errors/NG5000.md b/aio/content/errors/NG5000.md new file mode 100644 index 000000000000..f5d2f078ac3a --- /dev/null +++ b/aio/content/errors/NG5000.md @@ -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). diff --git a/aio/content/guide/hydration.md b/aio/content/guide/hydration.md index e3f76e259543..0940d95ecc6b 100644 --- a/aio/content/guide/hydration.md +++ b/aio/content/guide/hydration.md @@ -65,7 +65,7 @@ After you've followed these steps and have started up your server, load your app -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. @@ -110,6 +110,13 @@ If you choose to set this setting in your tsconfig, we recommend to set it only +### 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. + + ## Errors diff --git a/goldens/public-api/platform-browser/errors.md b/goldens/public-api/platform-browser/errors.md new file mode 100644 index 000000000000..c987c255cb58 --- /dev/null +++ b/goldens/public-api/platform-browser/errors.md @@ -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) + +``` diff --git a/packages/animations/browser/src/errors.ts b/packages/animations/browser/src/errors.ts index 827ddfe61c82..a457cf6f0698 100644 --- a/packages/animations/browser/src/errors.ts +++ b/packages/animations/browser/src/errors.ts @@ -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, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 9998a87c6f07..2512c99dfa5a 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -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 diff --git a/packages/platform-browser/BUILD.bazel b/packages/platform-browser/BUILD.bazel index d123e5664471..d9a5e0306f45 100644 --- a/packages/platform-browser/BUILD.bazel +++ b/packages/platform-browser/BUILD.bazel @@ -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([ diff --git a/packages/platform-browser/src/errors.ts b/packages/platform-browser/src/errors.ts new file mode 100644 index 000000000000..33de84188767 --- /dev/null +++ b/packages/platform-browser/src/errors.ts @@ -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, +} diff --git a/packages/platform-browser/src/hydration.ts b/packages/platform-browser/src/hydration.ts index 79f335bdb1b3..b7054165ebc8 100644 --- a/packages/platform-browser/src/hydration.ts +++ b/packages/platform-browser/src/hydration.ts @@ -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 { resetTViewsFor(SimpleComponent, NestedComponent); - const appRef = await hydrate(html, SimpleComponent); + const appRef = await hydrate(html, SimpleComponent, [withDebugConsole()]); const compRef = getComponentRef(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('(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('(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({