Skip to content

Commit

Permalink
refactor(platform-browser): log a warning when a custom or a noop Zon…
Browse files Browse the repository at this point in the history
…eJS is used with hydration (angular#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 angular#49944
  • Loading branch information
AndrewKushnir authored and dylhunn committed Apr 24, 2023
1 parent eb5bc95 commit 3bcbfec
Show file tree
Hide file tree
Showing 9 changed files with 188 additions and 4 deletions.
14 changes: 14 additions & 0 deletions aio/content/errors/NG5000.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 8 additions & 1 deletion aio/content/guide/hydration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions goldens/public-api/platform-browser/errors.md
Original file line number Diff line number Diff line change
@@ -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)

```
4 changes: 4 additions & 0 deletions packages/animations/browser/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion packages/platform-browser/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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"])

Expand Down Expand Up @@ -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([
Expand Down
16 changes: 16 additions & 0 deletions packages/platform-browser/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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,
}
32 changes: 31 additions & 1 deletion packages/platform-browser/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 81 additions & 1 deletion packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down

0 comments on commit 3bcbfec

Please sign in to comment.