From 2933006eb5440506d75b3eb567f4c731be3b49bc Mon Sep 17 00:00:00 2001 From: splincode Date: Sat, 15 Aug 2026 21:19:44 +0300 Subject: [PATCH] fix(core): detect static text hydration mismatches Validate static text content in addition to node type so injected DOM with the same element shape cannot silently shift hydration claims. Report expected and actual content in NG0500 to identify the exact location of the mismatch. Refs #56392 --- packages/core/src/hydration/error_handling.ts | 16 ++++++++- .../test/full_app_hydration_spec.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) 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',