Skip to content

Commit

Permalink
refactor(core): avoid hydration warnings when RenderMode.Client is set
Browse files Browse the repository at this point in the history
With the newly-added `RenderMode` config for routes, some of the routes may have the `RenderMode.Client` mode enabled, while also having `provideClientHydration()` function in provider list at bootstrap. As a result, there was a false-positive warning in a console, notifying developers about hydration misconfiguration.

This commit adds extra logic to handle this situation and avoid such warnings.

Note: there is a change required on the CLI side to add an extra marker, which would activate the logic added in this commit.
  • Loading branch information
AndrewKushnir committed Sep 28, 2024
1 parent fbd5579 commit 3fa8e0d
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 30 deletions.
67 changes: 43 additions & 24 deletions packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import {cleanupDehydratedViews} from './cleanup';
import {
enableClaimDehydratedIcuCaseImpl,
enablePrepareI18nBlockForHydrationImpl,
isI18nHydrationEnabled,
setIsI18nHydrationSupportEnabled,
} from './i18n';
import {
Expand Down Expand Up @@ -143,6 +142,21 @@ function whenStableWithTimeout(appRef: ApplicationRef, injector: Injector): Prom
return whenStablePromise;
}

/**
* Defines a name of an attribute that is added to the <body> tag
* in the `index.html` file in case a given route was configured
* with `RenderMode.Client`. 'cm' is an abbreviation for "Client Mode".
*/
export const CLIENT_RENDER_MODE_FLAG = 'ngcm';

/**
* Checks whether the `RenderMode.Client` was defined for the current route.
*/
function isClientRenderModeEnabled() {
const doc = getDocument();
return isPlatformBrowser() && doc.body.hasAttribute(CLIENT_RENDER_MODE_FLAG);
}

/**
* Returns a set of providers required to setup hydration support
* for an application that is server side rendered. This function is
Expand All @@ -164,19 +178,6 @@ export function withDomHydration(): EnvironmentProviders {
// hydration annotations. Otherwise, keep hydration disabled.
const transferState = inject(TransferState, {optional: true});
isEnabled = !!transferState?.get(NGH_DATA_KEY, null);
if (!isEnabled && typeof ngDevMode !== 'undefined' && ngDevMode) {
const console = inject(Console);
const message = formatRuntimeError(
RuntimeErrorCode.MISSING_HYDRATION_ANNOTATIONS,
'Angular hydration was requested on the client, but there was no ' +
'serialized information present in the server response, ' +
'thus hydration was not enabled. ' +
'Make sure the `provideClientHydration()` is included into the list ' +
'of providers in the server part of the application configuration.',
);
// tslint:disable-next-line:no-console
console.warn(message);
}
}
if (isEnabled) {
performanceMarkFeature('NgHydration');
Expand All @@ -191,14 +192,30 @@ export function withDomHydration(): EnvironmentProviders {
// no way to turn it off (e.g. for tests), so we turn it off by default.
setIsI18nHydrationSupportEnabled(false);

// Since this function is used across both server and client,
// make sure that the runtime code is only added when invoked
// on the client. Moving forward, the `isPlatformBrowser` check should
// be replaced with a tree-shakable alternative (e.g. `isServer`
// flag).
if (isPlatformBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
if (!isPlatformBrowser()) {
return;
}

if (inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
// Since this function is used across both server and client,
// make sure that the runtime code is only added when invoked
// on the client. Moving forward, the `isPlatformBrowser` check should
// be replaced with a tree-shakable alternative (e.g. `isServer`
// flag).
verifySsrContentsIntegrity();
enableHydrationRuntimeSupport();
} else if (typeof ngDevMode !== 'undefined' && ngDevMode && !isClientRenderModeEnabled()) {
const console = inject(Console);
const message = formatRuntimeError(
RuntimeErrorCode.MISSING_HYDRATION_ANNOTATIONS,
'Angular hydration was requested on the client, but there was no ' +
'serialized information present in the server response, ' +
'thus hydration was not enabled. ' +
'Make sure the `provideClientHydration()` is included into the list ' +
'of providers in the server part of the application configuration.',
);
// tslint:disable-next-line:no-console
console.warn(message);
}
},
multi: true,
Expand Down Expand Up @@ -250,14 +267,16 @@ export function withI18nSupport(): Provider[] {
return [
{
provide: IS_I18N_HYDRATION_ENABLED,
useValue: true,
useFactory: () => inject(IS_HYDRATION_DOM_REUSE_ENABLED),
},
{
provide: ENVIRONMENT_INITIALIZER,
useValue: () => {
enableI18nHydrationRuntimeSupport();
setIsI18nHydrationSupportEnabled(true);
performanceMarkFeature('NgI18nHydration');
if (inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
enableI18nHydrationRuntimeSupport();
setIsI18nHydrationSupportEnabled(true);
performanceMarkFeature('NgI18nHydration');
}
},
multi: true,
},
Expand Down
20 changes: 19 additions & 1 deletion packages/platform-server/test/dom_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/

import {DOCUMENT} from '@angular/common';
import {ApplicationRef, Provider, Type, ɵsetDocument} from '@angular/core';
import {ApplicationRef, PLATFORM_ID, Provider, Type, ɵsetDocument} from '@angular/core';
import {CLIENT_RENDER_MODE_FLAG} from '@angular/core/src/hydration/api';
import {getComponentDef} from '@angular/core/src/render3/definition';
import {
bootstrapApplication,
Expand Down Expand Up @@ -102,6 +103,7 @@ export function hydrate(
const hydrationFeatures = options?.hydrationFeatures ?? [];
const providers = [
...envProviders,
{provide: PLATFORM_ID, useValue: 'browser'},
{provide: DOCUMENT, useFactory: _document, deps: []},
provideClientHydration(...hydrationFeatures),
];
Expand All @@ -112,6 +114,14 @@ export function hydrate(
export function render(doc: Document, html: string) {
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
const container = convertHtmlToDom(html, doc);

// If there was a client render mode marker present in HTML - apply it to the <body>
// element as well.
const hasClientModeMarker = new RegExp(` ${CLIENT_RENDER_MODE_FLAG}`, 'g').test(html);
if (hasClientModeMarker) {
doc.body.setAttribute(CLIENT_RENDER_MODE_FLAG, '');
}

Array.from(container.childNodes).forEach((node) => doc.body.appendChild(node));
}

Expand All @@ -136,3 +146,11 @@ export async function renderAndHydrate(
render(doc, html);
return hydrate(doc, component, options);
}

/**
* Clears document contents to have a clean state for the next test.
*/
export function clearDocument(doc: Document) {
doc.body.textContent = '';
doc.body.removeAttribute(CLIENT_RENDER_MODE_FLAG);
}
54 changes: 49 additions & 5 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ import {provideRouter, RouterOutlet, Routes} from '@angular/router';
import {provideServerRendering} from '../public_api';
import {renderApplication} from '../src/utils';

import {getAppContents, renderAndHydrate, resetTViewsFor, stripUtilAttributes} from './dom_utils';
import {
clearDocument,
getAppContents,
renderAndHydrate,
resetTViewsFor,
stripUtilAttributes,
} from './dom_utils';
import {CLIENT_RENDER_MODE_FLAG} from '@angular/core/src/hydration/api';

/**
* The name of the attribute that contains a slot index
Expand Down Expand Up @@ -214,6 +221,17 @@ function verifyHasNoLog(appRef: ApplicationRef, message: string) {
.toBe(false);
}

/**
* Verifies that there are no messages in a console.
*/
function verifyEmptyConsole(appRef: ApplicationRef) {
const console = appRef.injector.get(Console) as DebugConsole;
const logs = console.logs.filter(
(msg) => !msg.startsWith('Angular is running in development mode'),
);
expect(logs).toEqual([]);
}

function getHydrationInfoFromTransferState(input: string): string | undefined {
return input.match(/<script[^>]+>(.*?)<\/script>/)?.[1];
}
Expand Down Expand Up @@ -271,9 +289,7 @@ describe('platform-server hydration integration', () => {
doc = TestBed.inject(DOCUMENT);
});

afterEach(() => {
doc.body.textContent = '';
});
afterEach(() => clearDocument(doc));

/**
* This renders the application with server side rendering logic.
Expand Down Expand Up @@ -7079,7 +7095,7 @@ describe('platform-server hydration integration', () => {
}
});

it('should log an warning when there was no hydration info in the TransferState', async () => {
it('should log a warning when there was no hydration info in the TransferState', async () => {
@Component({
standalone: true,
selector: 'app',
Expand Down Expand Up @@ -7117,6 +7133,34 @@ describe('platform-server hydration integration', () => {
verifyNoNodesWereClaimedForHydration(clientRootNode);
verifyClientAndSSRContentsMatch(ssrContents, clientRootNode);
});

it(
'should not log a warning when there was no hydration info in the TransferState, ' +
'but a client mode marker is present',
async () => {
@Component({
standalone: true,
selector: 'app',
template: `Hi!`,
})
class SimpleComponent {}

const html = `<html><head></head><body ${CLIENT_RENDER_MODE_FLAG}><app></app></body></html>`;

resetTViewsFor(SimpleComponent);

const appRef = await renderAndHydrate(doc, html, SimpleComponent, {
envProviders: [withDebugConsole()],
});
const compRef = getComponentRef<SimpleComponent>(appRef);
appRef.tick();

verifyEmptyConsole(appRef);

const clientRootNode = compRef.location.nativeElement;
expect(clientRootNode.textContent).toContain('Hi!');
},
);
});

describe('@if', () => {
Expand Down

0 comments on commit 3fa8e0d

Please sign in to comment.