diff --git a/packages/core/src/hydration/api.ts b/packages/core/src/hydration/api.ts index e40d715aa0f5..fbead01c414a 100644 --- a/packages/core/src/hydration/api.ts +++ b/packages/core/src/hydration/api.ts @@ -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'; @@ -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); @@ -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.', - ); - } -} diff --git a/packages/core/src/hydration/utils.ts b/packages/core/src/hydration/utils.ts index 435147eed7dc..8cc38c47fec1 100644 --- a/packages/core/src/hydration/utils.ts +++ b/packages/core/src/hydration/utils.ts @@ -697,3 +697,65 @@ export function processBlockData(injector: Injector): Map } 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 , 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 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: + // Hi + const beforeBody = skipTextNodes(doc.body.previousSibling); + if (isSsrContentsIntegrity(beforeBody)) { + return; + } + + // Case 2: Implicit body & head. Example: + // Hi + 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.', + ); +} diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index c4c6058df000..ee6ff0b55336 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -345,6 +345,7 @@ "isReadableStreamLike", "isRefreshingViews", "isRootView", + "isSsrContentsIntegrity", "isSubscription", "isTemplateNode", "isTypeProvider", @@ -437,6 +438,7 @@ "shimStylesContent", "shouldSearchParent", "siblingAfter", + "skipTextNodes", "sortAndConcatParams", "storeLViewOnDestroy", "stringify", diff --git a/packages/core/test/hydration/marker_spec.ts b/packages/core/test/hydration/marker_spec.ts new file mode 100644 index 000000000000..15eeb8742492 --- /dev/null +++ b/packages/core/test/hydration/marker_spec.ts @@ -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 { + return new DOMParser().parseFromString(html, 'text/html'); + } + + it('fails without integrity marker comment', async () => { + const dom = await doc(''); + expect(() => verifySsrContentsIntegrity(dom)).toThrowError(/NG0507/); + }); + + it('succeeds with "complete" DOM', async () => { + const dom = await doc( + `Hi`, + ); + expect(() => verifySsrContentsIntegrity(dom)).not.toThrow(); + }); + + it('succeeds with -less DOM', async () => { + const dom = await doc( + `Hi`, + ); + expect(() => verifySsrContentsIntegrity(dom)).not.toThrow(); + }); + + it('succeeds with - and -less DOM', async () => { + const dom = await doc( + `Hi`, + ); + expect(() => verifySsrContentsIntegrity(dom)).not.toThrow(); + }); + + it('succeeds with -less DOM that contains whitespace', async () => { + const dom = await doc( + `Hi\n\n`, + ); + expect(() => verifySsrContentsIntegrity(dom)).not.toThrow(); + }); + + it('succeeds with - and -less DOM that contains whitespace', async () => { + const dom = await doc( + `Hi\n\n`, + ); + expect(() => verifySsrContentsIntegrity(dom)).not.toThrow(); + }); +});