diff --git a/packages/core/src/hydration/error_handling.ts b/packages/core/src/hydration/error_handling.ts index ccfe1fdffbb8..effd19bdee95 100644 --- a/packages/core/src/hydration/error_handling.ts +++ b/packages/core/src/hydration/error_handling.ts @@ -64,13 +64,17 @@ export function validateMatchingNode( tNode: TNode, isViewContainerAnchor = false, ): void { + const hasStaticTextContentMismatch = hasTextContentMismatch(node, tNode); + if ( !node || (node as Node).nodeType !== nodeType || + hasStaticTextContentMismatch || ((node as Node).nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName.toLowerCase() !== tagName?.toLowerCase()) ) { - const expectedNode = shortRNodeDescription(nodeType, tagName, null); + const expectedTextContent = hasStaticTextContentMismatch ? tNode.value : null; + const expectedNode = shortRNodeDescription(nodeType, tagName, expectedTextContent); let header = `During hydration Angular expected ${expectedNode} but `; const hostComponentDef = getDeclarationComponentDef(lView); @@ -120,6 +124,16 @@ export function validateMatchingNode( } } +function hasTextContentMismatch(node: RNode | null, tNode: TNode): boolean { + return ( + node !== null && + (node as Node).nodeType === Node.TEXT_NODE && + tNode.type === TNodeType.Text && + tNode.value.length > 0 && + (node as Node).textContent !== tNode.value + ); +} + /** * Validates that a given node has sibling nodes */ diff --git a/packages/platform-server/test/full_app_hydration_spec.ts b/packages/platform-server/test/full_app_hydration_spec.ts index ca0c1c58924a..b62341fc4e30 100644 --- a/packages/platform-server/test/full_app_hydration_spec.ts +++ b/packages/platform-server/test/full_app_hydration_spec.ts @@ -6174,6 +6174,41 @@ describe('platform-server full application hydration integration', () => { }); }); + it('should detect static text content mismatch', async () => { + @Component({ + selector: 'app', + template: ` + + `, + }) + class SimpleComponent { + private doc = inject(DOCUMENT); + private isServer = isPlatformServer(inject(PLATFORM_ID)); + + ngAfterViewInit() { + if (this.isServer) { + const injectedItem = this.doc.createElement('li'); + injectedItem.innerHTML = 'Sponsored'; + this.doc.querySelector('li')?.before(injectedItem); + } + } + } + + const html = await ssr(SimpleComponent); + + resetTViewsFor(SimpleComponent); + + await expectAsync( + prepareEnvironmentAndHydrate(doc, html, SimpleComponent, { + envProviders: [withNoopErrorHandler()], + }), + ).toBeRejectedWithError( + /During hydration Angular expected a text node \(with the "Alice" content\) but found a text node \(with the "Sponsored" content\)/, + ); + }); + it('should not crash when a node can not be found during hydration', async () => { @Component({ selector: 'app',