Skip to content

Commit 0a38dc3

Browse files
JeanMechedylhunn
authored andcommitted
refactor(core): throw an error when hydration marker is missing from DOM (angular#51170)
non-destructive hydration expects the DOM tree to have the same structure in both places. With this commit, the app will throw an error if comments are stripped out by the http server (eg by some CDNs). fixes angular#51160 PR Close angular#51170
1 parent 6d05a68 commit 0a38dc3

13 files changed

Lines changed: 127 additions & 46 deletions

File tree

goldens/public-api/core/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export const enum RuntimeErrorCode {
8585
// (undocumented)
8686
MISSING_REQUIRED_INJECTABLE_IN_BOOTSTRAP = 402,
8787
// (undocumented)
88+
MISSING_SSR_CONTENT_INTEGRITY_MARKER = 507,
89+
// (undocumented)
8890
MISSING_ZONEJS = 908,
8991
// (undocumented)
9092
MULTIPLE_COMPONENTS_MATCH = -300,

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {formatRuntimeError as ɵformatRuntimeError, RuntimeError as ɵRuntimeErr
1919
export {annotateForHydration as ɵannotateForHydration} from './hydration/annotate';
2020
export {withDomHydration as ɵwithDomHydration} from './hydration/api';
2121
export {IS_HYDRATION_DOM_REUSE_ENABLED as ɵIS_HYDRATION_DOM_REUSE_ENABLED} from './hydration/tokens';
22+
export {SSR_CONTENT_INTEGRITY_MARKER as ɵSSR_CONTENT_INTEGRITY_MARKER} from './hydration/utils';
2223
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';
2324
export {DEFAULT_LOCALE_ID as ɵDEFAULT_LOCALE_ID} from './i18n/localization';
2425
export {InitialRenderPendingTasks as ɵInitialRenderPendingTasks} from './initial_render_pending_tasks';

packages/core/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const enum RuntimeErrorCode {
7878
INVALID_SKIP_HYDRATION_HOST = -504,
7979
MISSING_HYDRATION_ANNOTATIONS = -505,
8080
HYDRATION_STABLE_TIMEDOUT = -506,
81+
MISSING_SSR_CONTENT_INTEGRITY_MARKER = 507,
8182

8283
// Signal Errors
8384
SIGNAL_WRITE_FROM_ILLEGAL_CONTEXT = 600,

packages/core/src/hydration/api.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,27 @@
99
import {first} from 'rxjs/operators';
1010

1111
import {APP_BOOTSTRAP_LISTENER, ApplicationRef} from '../application_ref';
12-
import {ENABLED_SSR_FEATURES, PLATFORM_ID} from '../application_tokens';
12+
import {ENABLED_SSR_FEATURES} from '../application_tokens';
1313
import {Console} from '../console';
1414
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, Injector, makeEnvironmentProviders} from '../di';
1515
import {inject} from '../di/injector_compatibility';
16-
import {formatRuntimeError, RuntimeErrorCode} from '../errors';
16+
import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors';
1717
import {enableLocateOrCreateContainerRefImpl} from '../linker/view_container_ref';
1818
import {enableLocateOrCreateElementNodeImpl} from '../render3/instructions/element';
1919
import {enableLocateOrCreateElementContainerNodeImpl} from '../render3/instructions/element_container';
2020
import {enableApplyRootElementTransformImpl} from '../render3/instructions/shared';
2121
import {enableLocateOrCreateContainerAnchorImpl} from '../render3/instructions/template';
2222
import {enableLocateOrCreateTextNodeImpl} from '../render3/instructions/text';
23+
import {getDocument} from '../render3/interfaces/document';
2324
import {isPlatformBrowser} from '../render3/util/misc_utils';
2425
import {TransferState} from '../transfer_state';
2526
import {NgZone} from '../zone';
2627

2728
import {cleanupDehydratedViews} from './cleanup';
2829
import {IS_HYDRATION_DOM_REUSE_ENABLED, PRESERVE_HOST_CONTENT} from './tokens';
29-
import {enableRetrieveHydrationInfoImpl, NGH_DATA_KEY} from './utils';
30+
import {enableRetrieveHydrationInfoImpl, NGH_DATA_KEY, SSR_CONTENT_INTEGRITY_MARKER} from './utils';
3031
import {enableFindMatchingDehydratedViewImpl} from './views';
3132

32-
3333
/**
3434
* Indicates whether the hydration-related code was added,
3535
* prevents adding it multiple times.
@@ -150,10 +150,11 @@ export function withDomHydration(): EnvironmentProviders {
150150
useValue: () => {
151151
// Since this function is used across both server and client,
152152
// make sure that the runtime code is only added when invoked
153-
// on the client. Moving forward, the `isBrowser` check should
153+
// on the client. Moving forward, the `isPlatformBrowser` check should
154154
// be replaced with a tree-shakable alternative (e.g. `isServer`
155155
// flag).
156156
if (isPlatformBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
157+
verifySsrContentsIntegrity();
157158
enableHydrationRuntimeSupport();
158159
}
159160
},
@@ -209,3 +210,34 @@ function logWarningOnStableTimedout(time: number, console: Console): void {
209210

210211
console.warn(formatRuntimeError(RuntimeErrorCode.HYDRATION_STABLE_TIMEDOUT, message));
211212
}
213+
214+
/**
215+
* Verifies whether the DOM contains a special marker added during SSR time to make sure
216+
* there is no SSR'ed contents transformations happen after SSR is completed. Typically that
217+
* happens either by CDN or during the build process as an optimization to remove comment nodes.
218+
* Hydration process requires comment nodes produced by Angular to locate correct DOM segments.
219+
* When this special marker is *not* present - throw an error and do not proceed with hydration,
220+
* since it will not be able to function correctly.
221+
*
222+
* Note: this function is invoked only on the client, so it's safe to use DOM APIs.
223+
*/
224+
function verifySsrContentsIntegrity(): void {
225+
const doc = getDocument();
226+
let hydrationMarker: Node|undefined;
227+
for (const node of doc.body.childNodes) {
228+
if (node.nodeType === Node.COMMENT_NODE &&
229+
node.textContent?.trim() === SSR_CONTENT_INTEGRITY_MARKER) {
230+
hydrationMarker = node;
231+
break;
232+
}
233+
}
234+
if (!hydrationMarker) {
235+
throw new RuntimeError(
236+
RuntimeErrorCode.MISSING_SSR_CONTENT_INTEGRITY_MARKER,
237+
typeof ngDevMode !== 'undefined' && ngDevMode &&
238+
'Angular hydration logic detected that HTML content of this page was modified after it ' +
239+
'was produced during server side rendering. Make sure that there are no optimizations ' +
240+
'that remove comment nodes from HTML are enabled on your CDN. Angular hydration ' +
241+
'relies on HTML produced by the server, including whitespaces and comment nodes.');
242+
}
243+
}

packages/core/src/hydration/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export const NGH_DATA_KEY = makeStateKey<Array<SerializedView>>(TRANSFER_STATE_T
3636
*/
3737
export const NGH_ATTR_NAME = 'ngh';
3838

39+
/**
40+
* Marker used in a comment node to ensure hydration content integrity
41+
*/
42+
export const SSR_CONTENT_INTEGRITY_MARKER = 'nghm';
43+
3944
export const enum TextNodeMarker {
4045

4146
/**

packages/platform-browser/src/hydration.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,6 @@ export function provideClientHydration(...features: HydrationFeature<HydrationFe
169169
providers.push(ɵproviders);
170170
}
171171
}
172-
173172
return makeEnvironmentProviders([
174173
(typeof ngDevMode !== 'undefined' && ngDevMode) ? provideZoneJsCompatibilityDetector() : [],
175174
(featuresKind.has(HydrationFeatureKind.NoDomReuseFeature) ? [] : withDomHydration()),

packages/platform-browser/test/hydration_spec.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {DOCUMENT} from '@angular/common';
1010
import {HttpClient, provideHttpClient} from '@angular/common/http';
1111
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
12-
import {ApplicationRef, Component, Injectable} from '@angular/core';
12+
import {ApplicationRef, Component, Injectable, ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core';
1313
import {TestBed} from '@angular/core/testing';
1414
import {withBody} from '@angular/private/testing';
1515
import {BehaviorSubject} from 'rxjs';
@@ -37,23 +37,24 @@ describe('provideClientHydration', () => {
3737
}
3838

3939
describe('default', () => {
40-
beforeEach(withBody('<test-hydrate-app></test-hydrate-app>', () => {
41-
TestBed.resetTestingModule();
40+
beforeEach(withBody(
41+
`<!--${SSR_CONTENT_INTEGRITY_MARKER}--><test-hydrate-app></test-hydrate-app>`, () => {
42+
TestBed.resetTestingModule();
4243

43-
TestBed.configureTestingModule({
44-
declarations: [SomeComponent],
45-
providers: [
46-
{provide: DOCUMENT, useFactory: () => document},
47-
{provide: ApplicationRef, useClass: ApplicationRefPatched},
48-
provideClientHydration(),
49-
provideHttpClient(),
50-
provideHttpClientTesting(),
51-
],
52-
});
44+
TestBed.configureTestingModule({
45+
declarations: [SomeComponent],
46+
providers: [
47+
{provide: DOCUMENT, useFactory: () => document},
48+
{provide: ApplicationRef, useClass: ApplicationRefPatched},
49+
provideClientHydration(),
50+
provideHttpClient(),
51+
provideHttpClientTesting(),
52+
],
53+
});
5354

54-
const appRef = TestBed.inject(ApplicationRef);
55-
appRef.bootstrap(SomeComponent);
56-
}));
55+
const appRef = TestBed.inject(ApplicationRef);
56+
appRef.bootstrap(SomeComponent);
57+
}));
5758

5859
it(`should use cached HTTP calls`, () => {
5960
makeRequestAndExpectOne('/test-1', 'foo');
@@ -63,23 +64,24 @@ describe('provideClientHydration', () => {
6364
});
6465

6566
describe('withNoHttpTransferCache', () => {
66-
beforeEach(withBody('<test-hydrate-app></test-hydrate-app>', () => {
67-
TestBed.resetTestingModule();
67+
beforeEach(withBody(
68+
`<!--${SSR_CONTENT_INTEGRITY_MARKER}--><test-hydrate-app></test-hydrate-app>`, () => {
69+
TestBed.resetTestingModule();
6870

69-
TestBed.configureTestingModule({
70-
declarations: [SomeComponent],
71-
providers: [
72-
{provide: DOCUMENT, useFactory: () => document},
73-
{provide: ApplicationRef, useClass: ApplicationRefPatched},
74-
provideClientHydration(withNoHttpTransferCache()),
75-
provideHttpClient(),
76-
provideHttpClientTesting(),
77-
],
78-
});
71+
TestBed.configureTestingModule({
72+
declarations: [SomeComponent],
73+
providers: [
74+
{provide: DOCUMENT, useFactory: () => document},
75+
{provide: ApplicationRef, useClass: ApplicationRefPatched},
76+
provideClientHydration(withNoHttpTransferCache()),
77+
provideHttpClient(),
78+
provideHttpClientTesting(),
79+
],
80+
});
7981

80-
const appRef = TestBed.inject(ApplicationRef);
81-
appRef.bootstrap(SomeComponent);
82-
}));
82+
const appRef = TestBed.inject(ApplicationRef);
83+
appRef.bootstrap(SomeComponent);
84+
}));
8385

8486
it(`should not cached HTTP calls`, () => {
8587
makeRequestAndExpectOne('/test-1', 'foo');

packages/platform-server/src/utils.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {ApplicationRef, InjectionToken, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵannotateForHydration as annotateForHydration, ɵENABLED_SSR_FEATURES as ENABLED_SSR_FEATURES, ɵInitialRenderPendingTasks as InitialRenderPendingTasks, ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED} from '@angular/core';
9+
import {ApplicationRef, InjectionToken, PlatformRef, Provider, Renderer2, StaticProvider, Type, ɵannotateForHydration as annotateForHydration, ɵENABLED_SSR_FEATURES as ENABLED_SSR_FEATURES, ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, ɵSSR_CONTENT_INTEGRITY_MARKER as SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core';
1010
import {first} from 'rxjs/operators';
1111

1212
import {PlatformState} from './platform_state';
@@ -31,6 +31,19 @@ function createServerPlatform(options: PlatformOptions): PlatformRef {
3131
]);
3232
}
3333

34+
/**
35+
* Creates a marker comment node and append it into the `<body>`.
36+
* Some CDNs have mechanisms to remove all comment node from HTML.
37+
* This behaviour breaks hydration, so we'll detect on the client side if this
38+
* marker comment is still available or else throw an error
39+
*/
40+
function appendSsrContentIntegrityMarker(doc: Document) {
41+
// Adding a ng hydration marken comment
42+
const comment = doc.createComment(SSR_CONTENT_INTEGRITY_MARKER);
43+
doc.body.firstChild ? doc.body.insertBefore(comment, doc.body.firstChild) :
44+
doc.body.append(comment);
45+
}
46+
3447
/**
3548
* Adds the `ng-server-context` attribute to host elements of all bootstrapped components
3649
* within a given application.
@@ -60,7 +73,9 @@ async function _render(platformRef: PlatformRef, applicationRef: ApplicationRef)
6073

6174
const platformState = platformRef.injector.get(PlatformState);
6275
if (applicationRef.injector.get(IS_HYDRATION_DOM_REUSE_ENABLED, false)) {
63-
annotateForHydration(applicationRef, platformState.getDocument());
76+
const doc = platformState.getDocument();
77+
appendSsrContentIntegrityMarker(doc);
78+
annotateForHydration(applicationRef, doc);
6479
}
6580

6681
// Run any BEFORE_APP_SERIALIZED callbacks just before rendering to string.

packages/platform-server/test/hydration_spec.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {CommonModule, DOCUMENT, isPlatformServer, NgComponentOutlet, NgFor, NgIf
1212
import {MockPlatformLocation} from '@angular/common/testing';
1313
import {afterRender, ApplicationRef, Component, ComponentRef, createComponent, destroyPlatform, Directive, ElementRef, EnvironmentInjector, ErrorHandler, getPlatform, inject, Injectable, Input, NgZone, PLATFORM_ID, Provider, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ɵsetDocument} from '@angular/core';
1414
import {Console} from '@angular/core/src/console';
15+
import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
1516
import {getComponentDef} from '@angular/core/src/render3/definition';
1617
import {NoopNgZone} from '@angular/core/src/zone/ng_zone';
1718
import {TestBed} from '@angular/core/testing';
@@ -89,6 +90,10 @@ function convertHtmlToDom(html: string, doc: Document): HTMLElement {
8990
return container;
9091
}
9192

93+
function stripSsrIntegrityMarker(input: string): string {
94+
return input.replace(`<!--${SSR_CONTENT_INTEGRITY_MARKER}-->`, '');
95+
}
96+
9297
function stripTransferDataScript(input: string): string {
9398
return input.replace(/<script (.*?)<\/script>/s, '');
9499
}
@@ -105,7 +110,8 @@ function whenStable(appRef: ApplicationRef): Promise<void> {
105110
function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootElement: HTMLElement) {
106111
const clientContents =
107112
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false));
108-
ssrContents = stripTransferDataScript(stripUtilAttributes(ssrContents, false));
113+
ssrContents =
114+
stripSsrIntegrityMarker(stripTransferDataScript(stripUtilAttributes(ssrContents, false)));
109115
expect(clientContents).toBe(ssrContents, 'Client and server contents mismatch');
110116
}
111117

@@ -280,7 +286,7 @@ describe('platform-server hydration integration', () => {
280286
hydrationFeatures: HydrationFeature<HydrationFeatureKind>[] = []): Promise<ApplicationRef> {
281287
// Get HTML contents of the `<app>`, create a DOM element and append it into the body.
282288
const container = convertHtmlToDom(html, doc);
283-
Array.from(container.children).forEach(node => doc.body.appendChild(node));
289+
Array.from(container.childNodes).forEach(node => doc.body.appendChild(node));
284290

285291
function _document(): any {
286292
ɵsetDocument(doc);
@@ -4122,14 +4128,15 @@ describe('platform-server hydration integration', () => {
41224128
appRef.tick();
41234129

41244130
const clientRootNode = compRef.location.nativeElement;
4125-
const portalRootNode = clientRootNode.ownerDocument.body.firstChild;
4131+
const portalRootNode = clientRootNode.ownerDocument.querySelector('portal-app');
41264132
verifyAllNodesClaimedForHydration(clientRootNode);
41274133
verifyAllNodesClaimedForHydration(portalRootNode.firstChild);
41284134
const clientContents = stripUtilAttributes(portalRootNode.outerHTML, false) +
41294135
stripUtilAttributes(clientRootNode.outerHTML, false);
41304136
expect(clientContents)
41314137
.toBe(
4132-
stripUtilAttributes(stripTransferDataScript(ssrContents), false),
4138+
stripSsrIntegrityMarker(
4139+
stripUtilAttributes(stripTransferDataScript(ssrContents), false)),
41334140
'Client and server contents mismatch');
41344141
});
41354142

packages/platform-server/test/integration_spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {DOCUMENT, isPlatformServer, PlatformLocation, ɵgetDOM as getDOM} from '
1212
import {HTTP_INTERCEPTORS, HttpClient, HttpClientModule, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest} from '@angular/common/http';
1313
import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing';
1414
import {ApplicationConfig, ApplicationRef, Component, destroyPlatform, EnvironmentProviders, getPlatform, HostListener, Inject, inject as coreInject, Injectable, Input, makeStateKey, mergeApplicationConfig, NgModule, NgZone, PLATFORM_ID, Provider, TransferState, Type, ViewEncapsulation} from '@angular/core';
15+
import {SSR_CONTENT_INTEGRITY_MARKER} from '@angular/core/src/hydration/utils';
1516
import {InitialRenderPendingTasks} from '@angular/core/src/initial_render_pending_tasks';
1617
import {TestBed} from '@angular/core/testing';
1718
import {bootstrapApplication, BrowserModule, provideClientHydration, Title, withNoDomReuse, withNoHttpTransferCache} from '@angular/platform-browser';
@@ -870,6 +871,22 @@ describe('platform-server integration', () => {
870871
expect(output).toMatch(/ng-server-context="other"/);
871872
});
872873

874+
it('appends SSR integrity marker comment when hydration is enabled', async () => {
875+
@Component({
876+
standalone: true,
877+
selector: 'app',
878+
template: ``,
879+
})
880+
class SimpleApp {
881+
}
882+
883+
const bootstrap = renderApplication(
884+
getStandaloneBoostrapFn(SimpleApp, [provideClientHydration()]), {document: doc});
885+
// HttpClient cache and DOM hydration are enabled by default.
886+
const output = await bootstrap;
887+
expect(output).toContain(`<body><!--${SSR_CONTENT_INTEGRITY_MARKER}-->`);
888+
});
889+
873890
it('includes a set of features into `ng-server-context` attribute', async () => {
874891
const options = {
875892
document: doc,

0 commit comments

Comments
 (0)