Skip to content

Commit

Permalink
feat(platform-browser): add a public API function to enable non-destr…
Browse files Browse the repository at this point in the history
…uctive hydration (#49666)

This commit adds the `provideClientHydration` function to the public API. This function can be used to enable the non-destructive Angular hydration.

Important note: the non-destructive hydration feature is in Developer Preview mode, learn more about it at https://angular.io/guide/releases#developer-preview.

Before you can get started with hydration, you must have a server side rendered (SSR) application. Follow the [Angular Universal Guide](https://angular.io/guide/universal) to enable server side rendering first. Once you have SSR working with your application, you can enable hydration by visiting your main app component or module and importing `provideClientHydration` from `@angular/platform-browser`. You'll then add that provider to your app's bootstrapping providers list.

```typescript
import {
  bootstrapApplication,
  provideClientHydration,
} from '@angular/platform-browser';
// ...
bootstrapApplication(RootCmp, {
  providers: [provideClientHydration()]
});
```

Alternatively if you are using NgModules, you would add `provideClientHydration` to your root app module's provider list.

```typescript
import {provideClientHydration} from '@angular/platform-browser';
import {NgModule} from '@angular/core';

@NgModule({
  declarations: [RootCmp],
  exports: [RootCmp],
  bootstrap: [RootCmp],
  providers: [provideClientHydration()],
})
export class AppModule {}
```

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.

Co-authored-by: jessicajaniuk <[email protected]>
Co-authored-by: alan-agius4 <[email protected]>

PR Close #49666
  • Loading branch information
AndrewKushnir authored and dylhunn committed Apr 4, 2023
1 parent b2327f4 commit 761e02d
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 44 deletions.
21 changes: 21 additions & 0 deletions goldens/public-api/platform-browser/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ApplicationRef } from '@angular/core';
import { ComponentRef } from '@angular/core';
import { DebugElement } from '@angular/core';
import { DebugNode } from '@angular/core';
import { EnvironmentProviders } from '@angular/core';
import * as i0 from '@angular/core';
import * as i1 from '@angular/common';
import { InjectionToken } from '@angular/core';
Expand Down Expand Up @@ -143,6 +144,17 @@ export class HammerModule {
static ɵmod: i0.ɵɵNgModuleDeclaration<HammerModule, never, never, never>;
}

// @public
export interface HydrationFeature<FeatureKind extends HydrationFeatureKind> {
// (undocumented)
ɵkind: FeatureKind;
// (undocumented)
ɵproviders: Provider[];
}

// @public
export type HydrationFeatures = NoDomReuseFeature;

// @public @deprecated
export const makeStateKey: typeof makeStateKey_2;

Expand Down Expand Up @@ -177,9 +189,15 @@ export type MetaDefinition = {
[prop: string]: string;
};

// @public
export type NoDomReuseFeature = HydrationFeature<HydrationFeatureKind.NoDomReuseFeature>;

// @public
export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef;

// @public
export function provideClientHydration(...features: HydrationFeatures[]): EnvironmentProviders;

// @public
export function provideProtractorTestingSupport(): Provider[];

Expand Down Expand Up @@ -232,6 +250,9 @@ export const TransferState: {
// @public (undocumented)
export const VERSION: Version;

// @public
export function withoutDomReuse(): NoDomReuseFeature;

// (No @packageDocumentation comment for this package)

```
2 changes: 1 addition & 1 deletion packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export {INJECTOR_SCOPE as ɵINJECTOR_SCOPE} from './di/scope';
export {XSS_SECURITY_URL as ɵXSS_SECURITY_URL} from './error_details_base_url';
export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeError} from './errors';
export {annotateForHydration as ɵannotateForHydration} from './hydration/annotate';
export {provideHydrationSupport as ɵprovideHydrationSupport} from './hydration/api';
export {withDomHydration as ɵwithDomHydration} from './hydration/api';
export {IS_HYDRATION_FEATURE_ENABLED as ɵIS_HYDRATION_FEATURE_ENABLED} from './hydration/tokens';
export {CurrencyIndex as ɵCurrencyIndex, ExtraLocaleDataIndex as ɵExtraLocaleDataIndex, findLocaleData as ɵfindLocaleData, getLocaleCurrencyCode as ɵgetLocaleCurrencyCode, getLocalePluralCase as ɵgetLocalePluralCase, LocaleDataIndex as ɵLocaleDataIndex, registerLocaleData as ɵregisterLocaleData, unregisterAllLocaleData as ɵunregisterLocaleData} from './i18n/locale_data_api';
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
Expand Down
41 changes: 5 additions & 36 deletions packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function printHydrationStats(console: Console) {
const message = `Angular hydrated ${ngDevMode!.hydratedComponents} component(s) ` +
`and ${ngDevMode!.hydratedNodes} node(s), ` +
`${ngDevMode!.componentsSkippedHydration} component(s) were skipped. ` +
`Note: this feature is in Developer Preview mode. ` +
`Learn more at https://angular.io/guides/hydration.`;
// tslint:disable-next-line:no-console
console.log(message);
Expand All @@ -92,47 +93,15 @@ function whenStable(

/**
* Returns a set of providers required to setup hydration support
* for an application that is server side rendered.
*
* ## NgModule-based bootstrap
*
* You can add the function call to the root AppModule of an application:
* ```
* import {provideHydrationSupport} from '@angular/core';
*
* @NgModule({
* providers: [
* // ... other providers ...
* provideHydrationSupport()
* ],
* declarations: [AppComponent],
* bootstrap: [AppComponent]
* })
* class AppModule {}
* ```
*
* ## Standalone-based bootstrap
*
* Add the function to the `bootstrapApplication` call:
* ```
* import {provideHydrationSupport} from '@angular/core';
*
* bootstrapApplication(RootComponent, {
* providers: [
* // ... other providers ...
* provideHydrationSupport()
* ]
* });
* ```
* for an application that is server side rendered. This function is
* included into the `provideClientHydration` public API function from
* the `platform-browser` package.
*
* The function sets up an internal flag that would be recognized during
* the server side rendering time as well, so there is no need to
* configure or change anything in NgUniversal to enable the feature.
*
* @publicApi
* @developerPreview
*/
export function provideHydrationSupport(): EnvironmentProviders {
export function withDomHydration(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: ENVIRONMENT_INITIALIZER,
Expand Down
120 changes: 120 additions & 0 deletions packages/platform-browser/src/hydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* @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
*/

import {EnvironmentProviders, makeEnvironmentProviders, Provider, ɵwithDomHydration as withDomHydration} from '@angular/core';

/**
* The list of features as an enum to uniquely type each feature.
*/
export const enum HydrationFeatureKind {
NoDomReuseFeature
}

/**
* Helper type to represent a Hydration feature.
*
* @publicApi
* @developerPreview
*/
export interface HydrationFeature<FeatureKind extends HydrationFeatureKind> {
ɵkind: FeatureKind;
ɵproviders: Provider[];
}

/**
* Helper function to create an object that represents a Hydration feature.
*/
function hydrationFeature<FeatureKind extends HydrationFeatureKind>(
kind: FeatureKind, providers: Provider[]): HydrationFeature<FeatureKind> {
return {ɵkind: kind, ɵproviders: providers};
}

/**
* A type alias that represents a feature which disables DOM reuse during hydration
* (effectively making Angular re-render the whole application from scratch).
* The type is used to describe the return value of the `withoutDomReuse` function.
*
* @see `withoutDomReuse`
* @see `provideClientHydration`
*
* @publicApi
* @developerPreview
*/
export type NoDomReuseFeature = HydrationFeature<HydrationFeatureKind.NoDomReuseFeature>;

/**
* Disables DOM nodes reuse during hydration. Effectively makes
* Angular re-render an application from scratch on the client.
*
* @publicApi
* @developerPreview
*/
export function withoutDomReuse(): NoDomReuseFeature {
// This feature has no providers and acts as a flag that turns off
// non-destructive hydration (which otherwise is turned on by default).
const providers: Provider[] = [];
return hydrationFeature(HydrationFeatureKind.NoDomReuseFeature, providers);
}

/**
* A type alias that represents all Hydration features available for use with
* `provideClientHydration`. Features can be enabled by adding special functions to the
* `provideClientHydration` call. See documentation for each symbol to find corresponding
* function name. See also `provideClientHydration` documentation on how to use those functions.
*
* @see `provideClientHydration`
*
* @publicApi
* @developerPreview
*/
export type HydrationFeatures = NoDomReuseFeature;

/**
* Sets up providers necessary to enable hydration functionality for the application.
* By default, the function enables the recommended set of features for the optimal
* performance for most of the applications. You can enable/disable features by
* passing special functions (from the `HydrationFeatures` set) as arguments to the
* `provideClientHydration` function.
*
* @usageNotes
*
* Basic example of how you can enable hydration in your application when
* `bootstrapApplication` function is used:
* ```
* bootstrapApplication(AppComponent, {
* providers: [provideClientHydration()]
* });
* ```
*
* Alternatively if you are using NgModules, you would add `provideClientHydration`
* to your root app module's provider list.
* ```
* @NgModule({
* declarations: [RootCmp],
* bootstrap: [RootCmp],
* providers: [provideClientHydration()],
* })
* export class AppModule {}
* ```
*
* @see `HydrationFeatures`
*
* @param features Optional features to configure additional router behaviors.
* @returns A set of providers to enable hydration.
*
* @publicApi
* @developerPreview
*/
export function provideClientHydration(...features: HydrationFeatures[]): EnvironmentProviders {
const shouldUseDomHydration =
!features.find(feature => feature.ɵkind === HydrationFeatureKind.NoDomReuseFeature);
return makeEnvironmentProviders([
(shouldUseDomHydration ? withDomHydration() : []),
features.map(feature => feature.ɵproviders),
]);
}
1 change: 1 addition & 0 deletions packages/platform-browser/src/platform-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export {REMOVE_STYLES_ON_COMPONENT_DESTROY} from './dom/dom_renderer';
export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager';
export {HAMMER_GESTURE_CONFIG, HAMMER_LOADER, HammerGestureConfig, HammerLoader, HammerModule} from './dom/events/hammer_gestures';
export {DomSanitizer, SafeHtml, SafeResourceUrl, SafeScript, SafeStyle, SafeUrl, SafeValue} from './security/dom_sanitization_service';
export {HydrationFeature, provideClientHydration, NoDomReuseFeature, HydrationFeatures, withoutDomReuse} from './hydration';

export * from './private_export';
export {VERSION} from './version';
83 changes: 76 additions & 7 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import '@angular/localize/init';

import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf, NgTemplateOutlet, PlatformLocation} from '@angular/common';
import {MockPlatformLocation} from '@angular/common/testing';
import {ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵprovideHydrationSupport as provideHydrationSupport, ɵsetDocument} from '@angular/core';
import {ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ɵsetDocument} from '@angular/core';
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 {TestBed} from '@angular/core/testing';
import {bootstrapApplication} from '@angular/platform-browser';
import {bootstrapApplication, HydrationFeatures, provideClientHydration, withoutDomReuse} from '@angular/platform-browser';
import {provideRouter, RouterOutlet, Routes} from '@angular/router';
import {first} from 'rxjs/operators';

Expand Down Expand Up @@ -128,6 +128,25 @@ function verifyAllNodesClaimedForHydration(el: HTMLElement, exceptions: HTMLElem
}
}

/**
* Walks over DOM nodes starting from a given node and make sure
* those nodes were not annotated as "claimed" by hydration.
* This helper function is needed to verify that the non-destructive
* hydration feature can be turned off.
*/
function verifyNoNodesWereClaimedForHydration(el: HTMLElement) {
if ((el as any).__claimed) {
fail(
'Unexpected state: the following node was hydrated, when the test ' +
'expects the node to be re-created instead: ' + el.outerHTML);
}
let current = el.firstChild;
while (current) {
verifyNoNodesWereClaimedForHydration(current as HTMLElement);
current = current.nextSibling;
}
}

/**
* Verifies whether a console has a log entry that contains a given message.
*/
Expand All @@ -138,6 +157,17 @@ function verifyHasLog(appRef: ApplicationRef, message: string) {
expect(console.logs.some(log => log.includes(message))).withContext(context).toBe(true);
}

/**
* Verifies that there is no message with a particular content in a console.
*/
function verifyHasNoLog(appRef: ApplicationRef, message: string) {
const console = appRef.injector.get(Console) as DebugConsole;
const context = `Expected '${message}' to be present in the log, but it was not found. ` +
`Logs content: ${JSON.stringify(console.logs)}`;
expect(console.logs.some(log => log.includes(message))).withContext(context).toBe(false);
}


/**
* Reset TView, so that we re-enter the first create pass as
* we would normally do when we hydrate on the client. Otherwise,
Expand Down Expand Up @@ -215,12 +245,13 @@ describe('platform-server integration', () => {
* @returns a promise containing the server rendered app as a string
*/
async function ssr(
component: Type<unknown>, doc?: string, envProviders?: Provider[]): Promise<string> {
component: Type<unknown>, doc?: string, envProviders?: Provider[],
hydrationFeatures?: HydrationFeatures[]): Promise<string> {
const defaultHtml = '<html><head></head><body><app></app></body></html>';
const providers = [
...(envProviders ?? []),
provideServerRendering(),
provideHydrationSupport(),
provideClientHydration(...(hydrationFeatures || [])),
];

const bootstrap = () => bootstrapApplication(component, {providers});
Expand All @@ -239,8 +270,9 @@ describe('platform-server integration', () => {
* @param envProviders the environment providers
* @returns a promise with the application ref
*/
async function hydrate(html: string, component: Type<unknown>, envProviders?: Provider[]):
Promise<ApplicationRef> {
async function hydrate(
html: string, component: Type<unknown>, envProviders?: Provider[],
hydrationFeatures?: HydrationFeatures[]): Promise<ApplicationRef> {
// Destroy existing platform, a new one will be created later by the `bootstrapApplication`.
destroyPlatform();

Expand All @@ -257,12 +289,49 @@ describe('platform-server integration', () => {
const providers = [
...(envProviders ?? []),
{provide: DOCUMENT, useFactory: _document, deps: []},
provideHydrationSupport(),
provideClientHydration(...(hydrationFeatures || [])),
];

return bootstrapApplication(component, {providers});
}

describe('public API', () => {
it('should allow to disable DOM hydration using `withoutDomReuse` feature', async () => {
@Component({
standalone: true,
selector: 'app',
template: `
<header>Header</header>
<main>This is hydrated content in the main element.</main>
<footer>Footer</footer>
`,
})
class SimpleComponent {
}

const html =
await ssr(SimpleComponent, undefined, [withDebugConsole()], [withoutDomReuse()]);
const ssrContents = getAppContents(html);

// There should be no `ngh` annotations.
expect(ssrContents).not.toContain(`<app ${NGH_ATTR_NAME}`);

resetTViewsFor(SimpleComponent);

const appRef =
await hydrate(html, SimpleComponent, [withDebugConsole()], [withoutDomReuse()]);
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

// Make sure there is no hydration-related message in a console.
verifyHasNoLog(appRef, 'Angular hydrated');

const clientRootNode = compRef.location.nativeElement;
verifyNoNodesWereClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});
});

describe('annotations', () => {
it('should add hydration annotations to component host nodes during ssr', async () => {
@Component({
Expand Down

0 comments on commit 761e02d

Please sign in to comment.