From 3fa8e0dcf44830969ba00faa3dc79ba59a8885b7 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Sat, 28 Sep 2024 12:24:09 -0700 Subject: [PATCH] refactor(core): avoid hydration warnings when `RenderMode.Client` is set 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. --- packages/core/src/hydration/api.ts | 67 ++++++++++++------- packages/platform-server/test/dom_utils.ts | 20 +++++- .../platform-server/test/hydration_spec.ts | 54 +++++++++++++-- 3 files changed, 111 insertions(+), 30 deletions(-) diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index d2ba107272eb0a..04bc090ddbc400 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -34,7 +34,6 @@ import {cleanupDehydratedViews} from './cleanup'; import { enableClaimDehydratedIcuCaseImpl, enablePrepareI18nBlockForHydrationImpl, - isI18nHydrationEnabled, setIsI18nHydrationSupportEnabled, } from './i18n'; import { @@ -143,6 +142,21 @@ function whenStableWithTimeout(appRef: ApplicationRef, injector: Injector): Prom return whenStablePromise; } +/** + * Defines a name of an attribute that is added to the 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 @@ -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'); @@ -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, @@ -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, }, diff --git a/packages/platform-server/test/dom_utils.ts b/packages/platform-server/test/dom_utils.ts index 0a153463bc971c..f1715be906775a 100644 --- a/packages/platform-server/test/dom_utils.ts +++ b/packages/platform-server/test/dom_utils.ts @@ -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, @@ -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), ]; @@ -112,6 +114,14 @@ export function hydrate( export function render(doc: Document, html: string) { // Get HTML contents of the ``, 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 + // 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)); } @@ -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); +} diff --git a/packages/platform-server/test/hydration_spec.ts b/packages/platform-server/test/hydration_spec.ts index 205d682eb85c8d..2feac14a0b8a72 100644 --- a/packages/platform-server/test/hydration_spec.ts +++ b/packages/platform-server/test/hydration_spec.ts @@ -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 @@ -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>/)?.[1]; } @@ -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. @@ -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', @@ -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 = ``; + + resetTViewsFor(SimpleComponent); + + const appRef = await renderAndHydrate(doc, html, SimpleComponent, { + envProviders: [withDebugConsole()], + }); + const compRef = getComponentRef(appRef); + appRef.tick(); + + verifyEmptyConsole(appRef); + + const clientRootNode = compRef.location.nativeElement; + expect(clientRootNode.textContent).toContain('Hi!'); + }, + ); }); describe('@if', () => {