Skip to content

Commit f6079b7

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 angular#59224
1 parent 5f74e7e commit f6079b7

2 files changed

Lines changed: 79 additions & 1 deletion

File tree

packages/core/src/hydration/error_handling.ts

Lines changed: 38 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';
1819

1920
const AT_THIS_LOCATION = '<-- AT THIS LOCATION';
2021

22+
const HYDRATION_GUIDE_URL = 'https://angular.dev/guide/hydration';
23+
const THIRD_PARTY_SCRIPTS_URL = `${HYDRATION_GUIDE_URL}#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,19 @@ 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+
`Consider using \`afterNextRender\` to delay script execution until after hydration completes. ` +
117+
`More info: ${THIRD_PARTY_SCRIPTS_URL}\n\n`;
118+
}
119+
104120
throw new RuntimeError(RuntimeErrorCode.HYDRATION_NODE_MISMATCH, message);
105121
}
106122
}
@@ -413,11 +429,32 @@ function getHydrationErrorFooter(componentClassName?: string): string {
413429
`To fix this problem:\n` +
414430
` * check ${componentInfo} component for hydration-related issues\n` +
415431
` * check to see if your template has valid HTML structure\n` +
432+
` * check if there are any third-party scripts that manipulate the DOM. More info: ${THIRD_PARTY_SCRIPTS_URL}\n` +
416433
` * or skip hydration by adding the \`ngSkipHydration\` attribute ` +
417434
`to its host node in a template\n\n`
418435
);
419436
}
420437

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

packages/platform-server/test/full_app_hydration_spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6197,10 +6197,51 @@ 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(
6201+
'check if there are any third-party scripts that manipulate the DOM. More info: https://angular.dev/guide/hydration#third-party-scripts-with-dom-manipulation',
6202+
);
62006203
verifyNodeHasMismatchInfo(doc);
62016204
});
62026205
});
62036206

6207+
it('should suggest afterNextRender if a third-party script injected a node', async () => {
6208+
@Component({
6209+
selector: 'app',
6210+
template: `<div>Original content</div>`,
6211+
standalone: true,
6212+
})
6213+
class SimpleComponent {
6214+
private doc = inject(DOCUMENT);
6215+
ngAfterViewInit() {
6216+
const div = this.doc.querySelector('div');
6217+
const ins = this.doc.createElement('ins');
6218+
ins.setAttribute('data-ad-client', 'ca-pub-1234');
6219+
ins.textContent = 'Ad content';
6220+
div?.parentNode?.insertBefore(ins, div);
6221+
}
6222+
}
6223+
6224+
const html = await ssr(SimpleComponent);
6225+
resetTViewsFor(SimpleComponent);
6226+
6227+
await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
6228+
envProviders: [withNoopErrorHandler()],
6229+
}).catch((err: unknown) => {
6230+
const message = (err as Error).message;
6231+
expect(message).toContain('During hydration Angular expected <div> but found <ins>');
6232+
expect(message).toContain(
6233+
'check if there are any third-party scripts that manipulate the DOM. More info: https://angular.dev/guide/hydration#third-party-scripts-with-dom-manipulation',
6234+
);
6235+
expect(message).toContain(
6236+
'Note: It looks like this node was injected by a third-party script or browser extension',
6237+
);
6238+
expect(message).toContain(
6239+
'Angular hydration does not support nodes injected outside of the Angular-managed DOM',
6240+
);
6241+
expect(message).toContain('Consider using `afterNextRender` to delay script execution');
6242+
});
6243+
});
6244+
62046245
it('should handle <ng-container> node mismatch', async () => {
62056246
@Component({
62066247
selector: 'app',

0 commit comments

Comments
 (0)