Skip to content

Commit 6578a99

Browse files
committed
refactor: Improve hydration mismatch errors for third-party scripts
Improves error messages shown during hydration mismatches to better surface cases where third-party scripts or browser extensions have modified the DOM outside of Angular's control. Fixed #59224
1 parent 5f74e7e commit 6578a99

File tree

2 files changed

+66
-1
lines changed

2 files changed

+66
-1
lines changed

packages/core/src/hydration/error_handling.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@ import {HOST, LView, TVIEW} from '../render3/interfaces/view';
1414
import {getParentRElement} from '../render3/node_manipulation';
1515
import {unwrapRNode} from '../render3/util/view_utils';
1616

17+
import {readPatchedData} from '../render3/context_discovery';
1718
import {markRNodeAsHavingHydrationMismatch} from './utils';
19+
import {DOC_PAGE_BASE_URL} from '../../../core/src/error_details_base_url';
1820

1921
const AT_THIS_LOCATION = '<-- AT THIS LOCATION';
2022

23+
const THIRD_PARTY_SCRIPTS_URL = `/guide/hydration#third-party-scripts-with-dom-manipulation`;
24+
2125
/**
2226
* Retrieves a user friendly string for a given TNodeType for use in
2327
* friendly error messages
@@ -100,7 +104,18 @@ export function validateMatchingNode(
100104
}
101105

102106
const footer = getHydrationErrorFooter(componentClassName);
103-
const message = header + expected + actual + getHydrationAttributeNote() + footer;
107+
let message = header + expected + actual + getHydrationAttributeNote() + footer;
108+
109+
// Check both when a mismatching node is found AND when the expected node is missing,
110+
// since third-party scripts can both inject extra nodes and remove existing ones.
111+
if (!node || (node && isLikelyExternalSourceNode(node))) {
112+
message +=
113+
`Note: It looks like this mismatch may have been caused by a third-party script or ` +
114+
`browser extension that modified the DOM outside of Angular's control. ` +
115+
`Angular hydration does not support nodes injected or removed outside of the Angular-managed DOM. ` +
116+
`Note: If you know which element in the DOM this will be inserted, consider adding ngSkipHydration to prevent this error. \n\n`;
117+
}
118+
104119
throw new RuntimeError(RuntimeErrorCode.HYDRATION_NODE_MISMATCH, message);
105120
}
106121
}
@@ -413,11 +428,32 @@ function getHydrationErrorFooter(componentClassName?: string): string {
413428
`To fix this problem:\n` +
414429
` * check ${componentInfo} component for hydration-related issues\n` +
415430
` * check to see if your template has valid HTML structure\n` +
431+
` * check if there are any third-party scripts that manipulate the DOM. More info: ${DOC_PAGE_BASE_URL}${THIRD_PARTY_SCRIPTS_URL}\n` +
416432
` * or skip hydration by adding the \`ngSkipHydration\` attribute ` +
417433
`to its host node in a template\n\n`
418434
);
419435
}
420436

437+
/**
438+
* Checks if a given RNode is likely to have been added by a third-party script
439+
* or browser extension, by checking whether Angular has any knowledge of it
440+
* via patched data. Nodes created and managed by Angular will always have
441+
* patched data attached to them.
442+
*/
443+
function isLikelyExternalSourceNode(rNode: RNode): boolean {
444+
const node = rNode as Node;
445+
if (node.nodeType !== Node.ELEMENT_NODE) {
446+
return false;
447+
}
448+
// If Angular has patched this node, it was created within Angular's context.
449+
if (readPatchedData(node as HTMLElement)) {
450+
return false;
451+
}
452+
// No patched data means Angular has no record of this node —
453+
// it was likely injected by a third-party script or browser extension.
454+
return true;
455+
}
456+
421457
/**
422458
* An attribute related note for hydration errors
423459
*/

packages/platform-server/test/full_app_hydration_spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6197,10 +6197,39 @@ describe('platform-server full application hydration integration', () => {
61976197
expect(message).toContain('During hydration Angular expected <b> but found <span>');
61986198
expect(message).toContain('<b>…</b> <-- AT THIS LOCATION');
61996199
expect(message).toContain('<span>…</span> <-- AT THIS LOCATION');
6200+
expect(message).toContain('/guide/hydration#third-party-scripts-with-dom-manipulation');
62006201
verifyNodeHasMismatchInfo(doc);
62016202
});
62026203
});
62036204

6205+
it('should if there are any third-party scripts that manipulate the DOM', async () => {
6206+
@Component({
6207+
selector: 'app',
6208+
template: `<div>Original content</div>`,
6209+
})
6210+
class SimpleComponent {
6211+
private doc = inject(DOCUMENT);
6212+
ngAfterViewInit() {
6213+
const div = this.doc.querySelector('div');
6214+
const ins = this.doc.createElement('ins');
6215+
ins.setAttribute('data-ad-client', 'ca-pub-1234');
6216+
ins.textContent = 'Ad content';
6217+
div?.parentNode?.insertBefore(ins, div);
6218+
}
6219+
}
6220+
6221+
const html = await ssr(SimpleComponent);
6222+
resetTViewsFor(SimpleComponent);
6223+
6224+
await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
6225+
envProviders: [withNoopErrorHandler()],
6226+
}).catch((err: unknown) => {
6227+
const message = (err as Error).message;
6228+
expect(message).toContain('During hydration Angular expected <div> but found <ins>');
6229+
expect(message).toContain('/guide/hydration#third-party-scripts-with-dom-manipulation');
6230+
});
6231+
});
6232+
62046233
it('should handle <ng-container> node mismatch', async () => {
62056234
@Component({
62066235
selector: 'app',

0 commit comments

Comments
 (0)