Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 2 additions & 37 deletions packages/core/src/hydration/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
isIncrementalHydrationEnabled,
NGH_DATA_KEY,
processBlockData,
SSR_CONTENT_INTEGRITY_MARKER,
verifySsrContentsIntegrity,
} from './utils';
import {enableFindMatchingDehydratedViewImpl} from './views';
import {DEHYDRATED_BLOCK_REGISTRY, DehydratedBlockRegistry} from '../defer/registry';
Expand Down Expand Up @@ -238,7 +238,7 @@ export function withDomHydration(): EnvironmentProviders {
}

if (inject(IS_HYDRATION_DOM_REUSE_ENABLED)) {
verifySsrContentsIntegrity();
verifySsrContentsIntegrity(getDocument());
enableHydrationRuntimeSupport();
} else if (typeof ngDevMode !== 'undefined' && ngDevMode && !isClientRenderModeEnabled()) {
const console = inject(Console);
Expand Down Expand Up @@ -397,38 +397,3 @@ function logWarningOnStableTimedout(time: number, console: Console): void {

console.warn(formatRuntimeError(RuntimeErrorCode.HYDRATION_STABLE_TIMEDOUT, message));
}

/**
* Verifies whether the DOM contains a special marker added during SSR time to make sure
* there is no SSR'ed contents transformations happen after SSR is completed. Typically that
* happens either by CDN or during the build process as an optimization to remove comment nodes.
* Hydration process requires comment nodes produced by Angular to locate correct DOM segments.
* When this special marker is *not* present - throw an error and do not proceed with hydration,
* since it will not be able to function correctly.
*
* Note: this function is invoked only on the client, so it's safe to use DOM APIs.
*/
function verifySsrContentsIntegrity(): void {
const doc = getDocument();
let hydrationMarker: Node | undefined;
for (const node of doc.body.childNodes) {
if (
node.nodeType === Node.COMMENT_NODE &&
node.textContent?.trim() === SSR_CONTENT_INTEGRITY_MARKER
) {
hydrationMarker = node;
break;
}
}
if (!hydrationMarker) {
throw new RuntimeError(
RuntimeErrorCode.MISSING_SSR_CONTENT_INTEGRITY_MARKER,
typeof ngDevMode !== 'undefined' &&
ngDevMode &&
'Angular hydration logic detected that HTML content of this page was modified after it ' +
'was produced during server side rendering. Make sure that there are no optimizations ' +
'that remove comment nodes from HTML enabled on your CDN. Angular hydration ' +
'relies on HTML produced by the server, including whitespaces and comment nodes.',
);
}
}
62 changes: 62 additions & 0 deletions packages/core/src/hydration/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,3 +697,65 @@ export function processBlockData(injector: Injector): Map<string, BlockSummary>
}
return blockDetails;
}

function isSsrContentsIntegrity(node: ChildNode | null): boolean {
return (
!!node &&
node.nodeType === Node.COMMENT_NODE &&
node.textContent?.trim() === SSR_CONTENT_INTEGRITY_MARKER
);
}

function skipTextNodes(node: ChildNode | null): ChildNode | null {
// Ignore whitespace. Before the <body>, we shouldn't find text nodes that aren't whitespace.
while (node && node.nodeType === Node.TEXT_NODE) {
node = node.previousSibling;
}
return node;
}

/**
* Verifies whether the DOM contains a special marker added during SSR time to make sure
* there is no SSR'ed contents transformations happen after SSR is completed. Typically that
* happens either by CDN or during the build process as an optimization to remove comment nodes.
* Hydration process requires comment nodes produced by Angular to locate correct DOM segments.
* When this special marker is *not* present - throw an error and do not proceed with hydration,
* since it will not be able to function correctly.
*
* Note: this function is invoked only on the client, so it's safe to use DOM APIs.
*/
export function verifySsrContentsIntegrity(doc: Document): void {
for (const node of doc.body.childNodes) {
if (isSsrContentsIntegrity(node)) {
return;
}
}

// Check if the HTML parser may have moved the marker to just before the <body> tag,
// e.g. because the body tag was implicit and not present in the markup. An implicit body
// tag is unlikely to interfer with whitespace/comments inside of the app's root element.

// Case 1: Implicit body. Example:
// <!doctype html><head><title>Hi</title></head><!--nghm--><app-root></app-root>
const beforeBody = skipTextNodes(doc.body.previousSibling);
if (isSsrContentsIntegrity(beforeBody)) {
return;
}

// Case 2: Implicit body & head. Example:
// <!doctype html><head><title>Hi</title><!--nghm--><app-root></app-root>
let endOfHead = skipTextNodes(doc.head.lastChild);
if (isSsrContentsIntegrity(endOfHead)) {
return;
}

throw new RuntimeError(
RuntimeErrorCode.MISSING_SSR_CONTENT_INTEGRITY_MARKER,
typeof ngDevMode !== 'undefined' &&
ngDevMode &&
'Angular hydration logic detected that HTML content of this page was modified after it ' +
'was produced during server side rendering. Make sure that there are no optimizations ' +
'that remove comment nodes from HTML enabled on your CDN. Angular hydration ' +
'relies on HTML produced by the server, including whitespaces and comment nodes.',
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@
"isReadableStreamLike",
"isRefreshingViews",
"isRootView",
"isSsrContentsIntegrity",
"isSubscription",
"isTemplateNode",
"isTypeProvider",
Expand Down Expand Up @@ -437,6 +438,7 @@
"shimStylesContent",
"shouldSearchParent",
"siblingAfter",
"skipTextNodes",
"sortAndConcatParams",
"storeLViewOnDestroy",
"stringify",
Expand Down
62 changes: 62 additions & 0 deletions packages/core/test/hydration/marker_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @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.dev/license
*/

import {verifySsrContentsIntegrity, SSR_CONTENT_INTEGRITY_MARKER} from '../../src/hydration/utils';

describe('verifySsrContentsIntegrity', () => {
if (typeof DOMParser === 'undefined') {
it('is only tested in the browser', () => {
expect(typeof DOMParser).toBe('undefined');
});
return;
}

async function doc(html: string): Promise<Document> {
return new DOMParser().parseFromString(html, 'text/html');
}

it('fails without integrity marker comment', async () => {
const dom = await doc('<app-root></app-root>');
expect(() => verifySsrContentsIntegrity(dom)).toThrowError(/NG0507/);
});

it('succeeds with "complete" DOM', async () => {
const dom = await doc(
`<!doctype html><head><title>Hi</title></head><body><!--${SSR_CONTENT_INTEGRITY_MARKER}--><app-root></app-root></body>`,
);
expect(() => verifySsrContentsIntegrity(dom)).not.toThrow();
});

it('succeeds with <body>-less DOM', async () => {
const dom = await doc(
`<!doctype html><head><title>Hi</title></head><!--${SSR_CONTENT_INTEGRITY_MARKER}--><app-root></app-root>`,
);
expect(() => verifySsrContentsIntegrity(dom)).not.toThrow();
});

it('succeeds with <body>- and <head>-less DOM', async () => {
const dom = await doc(
`<!doctype html><title>Hi</title><!--${SSR_CONTENT_INTEGRITY_MARKER}--><app-root></app-root>`,
);
expect(() => verifySsrContentsIntegrity(dom)).not.toThrow();
});

it('succeeds with <body>-less DOM that contains whitespace', async () => {
const dom = await doc(
`<!doctype html><head><title>Hi</title></head>\n<!--${SSR_CONTENT_INTEGRITY_MARKER}-->\n<app-root></app-root>`,
);
expect(() => verifySsrContentsIntegrity(dom)).not.toThrow();
});

it('succeeds with <body>- and <head>-less DOM that contains whitespace', async () => {
const dom = await doc(
`<!doctype html><title>Hi</title>\n<!--${SSR_CONTENT_INTEGRITY_MARKER}-->\n<app-root></app-root>`,
);
expect(() => verifySsrContentsIntegrity(dom)).not.toThrow();
});
});