Skip to content

Commit

Permalink
refactor(core): throw an error when hydration marker is missing from DOM
Browse files Browse the repository at this point in the history
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
  • Loading branch information
JeanMeche committed Jul 26, 2023
1 parent d886887 commit 604aa26
Show file tree
Hide file tree
Showing 12 changed files with 76 additions and 15 deletions.
2 changes: 2 additions & 0 deletions goldens/public-api/core/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
MISSING_HYDRATION_ANNOTATIONS = -505,
// (undocumented)
MISSING_HYDRATION_MARKER_COMMENT = 507,
// (undocumented)
MISSING_INJECTION_CONTEXT = -203,
// (undocumented)
MISSING_INJECTION_TOKEN = 208,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const enum RuntimeErrorCode {
INVALID_SKIP_HYDRATION_HOST = -504,
MISSING_HYDRATION_ANNOTATIONS = -505,
HYDRATION_STABLE_TIMEDOUT = -506,
MISSING_HYDRATION_MARKER_COMMENT = 507,

// Signal Errors
SIGNAL_WRITE_FROM_ILLEGAL_CONTEXT = 600,
Expand Down
26 changes: 22 additions & 4 deletions packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,24 @@ import {first} from 'rxjs/operators';
import {APP_BOOTSTRAP_LISTENER, ApplicationRef} from '../application_ref';
import {ENABLED_SSR_FEATURES, PLATFORM_ID} from '../application_tokens';
import {Console} from '../console';
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, Injector, makeEnvironmentProviders} from '../di';
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, Injector, makeEnvironmentProviders,} from '../di';
import {inject} from '../di/injector_compatibility';
import {formatRuntimeError, RuntimeErrorCode} from '../errors';
import {formatRuntimeError, RuntimeError, RuntimeErrorCode} from '../errors';
import {enableLocateOrCreateContainerRefImpl} from '../linker/view_container_ref';
import {enableLocateOrCreateElementNodeImpl} from '../render3/instructions/element';
import {enableLocateOrCreateElementContainerNodeImpl} from '../render3/instructions/element_container';
import {enableApplyRootElementTransformImpl} from '../render3/instructions/shared';
import {enableLocateOrCreateContainerAnchorImpl} from '../render3/instructions/template';
import {enableLocateOrCreateTextNodeImpl} from '../render3/instructions/text';
import {getDocument} from '../render3/interfaces/document';
import {TransferState} from '../transfer_state';
import {NgZone} from '../zone';

import {cleanupDehydratedViews} from './cleanup';
import {IS_HYDRATION_DOM_REUSE_ENABLED, PRESERVE_HOST_CONTENT} from './tokens';
import {enableRetrieveHydrationInfoImpl, NGH_DATA_KEY} from './utils';
import {enableRetrieveHydrationInfoImpl, NGH_DATA_KEY, SSR_CONTENT_INTEGRITY_MARKER} from './utils';
import {enableFindMatchingDehydratedViewImpl} from './views';


/**
* Indicates whether the hydration-related code was added,
* prevents adding it multiple times.
Expand Down Expand Up @@ -162,6 +162,24 @@ export function withDomHydration(): EnvironmentProviders {
// be replaced with a tree-shakable alternative (e.g. `isServer`
// flag).
if (isBrowser() && inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
// On the client, the DOM must contain a hydration marker or hydration is broken
const doc = getDocument();
let hydrationMarker: Node|undefined;
for (const node of doc.body.childNodes) {
if (node.nodeType === Node.COMMENT_NODE &&
node.textContent === SSR_CONTENT_INTEGRITY_MARKER) {
hydrationMarker = node;
}
}
if (!hydrationMarker) {
throw new RuntimeError(
RuntimeErrorCode.MISSING_HYDRATION_MARKER_COMMENT,
typeof ngDevMode !== 'undefined' && ngDevMode &&
'Angular hydration was requested on the client, but there was no hydration marker comment found in the node DOM tree. ' +
'Make sure the DOM is provided without changes by the HTTP server.',
);
}

enableHydrationRuntimeSupport();
}
},
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/hydration/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const NGH_DATA_KEY = makeStateKey<Array<SerializedView>>(TRANSFER_STATE_T
*/
export const NGH_ATTR_NAME = 'ngh';

/**
* Marker used in a comment node to ensure hydration content integrity
*/
export const SSR_CONTENT_INTEGRITY_MARKER = 'nghm';

export const enum TextNodeMarker {

/**
Expand Down
1 change: 0 additions & 1 deletion packages/platform-browser/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ export function provideClientHydration(...features: HydrationFeature<HydrationFe
providers.push(ɵproviders);
}
}

return makeEnvironmentProviders([
(typeof ngDevMode !== 'undefined' && ngDevMode) ? provideZoneJsCompatibilityDetector() : [],
(featuresKind.has(HydrationFeatureKind.NoDomReuseFeature) ? [] : withDomHydration()),
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-browser/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('provideClientHydration', () => {
}

describe('default', () => {
beforeEach(withBody('<test-hydrate-app></test-hydrate-app>', () => {
beforeEach(withBody('<!--nghm--><test-hydrate-app></test-hydrate-app>', () => {
TestBed.resetTestingModule();

TestBed.configureTestingModule({
Expand All @@ -63,7 +63,7 @@ describe('provideClientHydration', () => {
});

describe('withNoHttpTransferCache', () => {
beforeEach(withBody('<test-hydrate-app></test-hydrate-app>', () => {
beforeEach(withBody('<!--nghm--><test-hydrate-app></test-hydrate-app>', () => {
TestBed.resetTestingModule();

TestBed.configureTestingModule({
Expand Down
16 changes: 15 additions & 1 deletion packages/platform-server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

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';
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} from '@angular/core';
import {first} from 'rxjs/operators';

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

/**
* Creates a comment to mark the document as generated by SSR.
* Some CDNs have mechanisms to remove all comment node from HTML.
* This behaviour breaks hydration, so we'll detect on the client side if this
* marker comment is still available or else throw an error
*/
function appendSsrContentIntegrityMarker(doc: Document) {
// Adding a ng hydration marken comment
const comment = doc.createComment('nghm');
doc.body.firstChild ? doc.body.insertBefore(comment, doc.body.firstChild) :
doc.body.append(comment);
}

/**
* Adds the `ng-server-context` attribute to host elements of all bootstrapped components
* within a given application.
Expand Down Expand Up @@ -60,6 +73,7 @@ async function _render(platformRef: PlatformRef, applicationRef: ApplicationRef)

const platformState = platformRef.injector.get(PlatformState);
if (applicationRef.injector.get(IS_HYDRATION_DOM_REUSE_ENABLED, false)) {
appendSsrContentIntegrityMarker(platformState.getDocument());
annotateForHydration(applicationRef, platformState.getDocument());
}

Expand Down
14 changes: 10 additions & 4 deletions packages/platform-server/test/hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ function convertHtmlToDom(html: string, doc: Document): HTMLElement {
return container;
}

function stripHydrationMarker(input: string): string {
return input.replace(/<!--nghm-->/s, '');
}

function stripTransferDataScript(input: string): string {
return input.replace(/<script (.*?)<\/script>/s, '');
}
Expand All @@ -105,7 +109,8 @@ function whenStable(appRef: ApplicationRef): Promise<void> {
function verifyClientAndSSRContentsMatch(ssrContents: string, clientAppRootElement: HTMLElement) {
const clientContents =
stripTransferDataScript(stripUtilAttributes(clientAppRootElement.outerHTML, false));
ssrContents = stripTransferDataScript(stripUtilAttributes(ssrContents, false));
ssrContents =
stripHydrationMarker(stripTransferDataScript(stripUtilAttributes(ssrContents, false)));
expect(clientContents).toBe(ssrContents, 'Client and server contents mismatch');
}

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

function _document(): any {
ɵsetDocument(doc);
Expand Down Expand Up @@ -4026,14 +4031,15 @@ describe('platform-server hydration integration', () => {
appRef.tick();

const clientRootNode = compRef.location.nativeElement;
const portalRootNode = clientRootNode.ownerDocument.body.firstChild;
const portalRootNode = clientRootNode.ownerDocument.querySelector('portal-app');
verifyAllNodesClaimedForHydration(clientRootNode);
verifyAllNodesClaimedForHydration(portalRootNode.firstChild);
const clientContents = stripUtilAttributes(portalRootNode.outerHTML, false) +
stripUtilAttributes(clientRootNode.outerHTML, false);
expect(clientContents)
.toBe(
stripUtilAttributes(stripTransferDataScript(ssrContents), false),
stripHydrationMarker(
stripUtilAttributes(stripTransferDataScript(ssrContents), false)),
'Client and server contents mismatch');
});

Expand Down
16 changes: 16 additions & 0 deletions packages/platform-server/test/integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,22 @@ describe('platform-server integration', () => {
expect(output).toMatch(/ng-server-context="other"/);
});

it('sets the hydration marker comment', async () => {
@Component({
standalone: true,
selector: 'app',
template: ``,
})
class SimpleApp {
}

const bootstrap = renderApplication(
getStandaloneBoostrapFn(SimpleApp, [provideClientHydration()]), {document: doc});
// HttpClient cache and DOM hydration are enabled by default.
const output = await bootstrap;
expect(output).toMatch(/<body><!--nghm-->/);
});

it('includes a set of features into `ng-server-context` attribute', async () => {
const options = {
document: doc,
Expand Down
2 changes: 1 addition & 1 deletion packages/tsconfig-build.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"target": "es2022",
// Keep the below in sync with ng_module.bzl
"useDefineForClassFields": false,
"lib": ["es2020", "dom"],
"lib": ["es2020", "dom", "dom.iterable"],
"skipLibCheck": true,
// don't auto-discover @types/node, it results in a ///<reference in the .d.ts output
"types": [],
Expand Down
2 changes: 1 addition & 1 deletion packages/tsconfig-tsec-base.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"extends": "./tsconfig-build.json",
"compilerOptions": {
"noEmit": true,
"lib": ["es2020", "dom"],
"lib": ["es2020", "dom", "dom.iterable"],
"plugins": [{"name": "tsec", "exemptionConfig": "./tsec-exemption.json"}]
}
}
2 changes: 1 addition & 1 deletion packages/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
},
"rootDir": ".",
"inlineSourceMap": true,
"lib": ["es2020", "dom"],
"lib": ["es2020", "dom", "DOM.Iterable"],
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"types": ["angular"]
Expand Down

0 comments on commit 604aa26

Please sign in to comment.