fix(platform-browser): throw a descriptive error when insertBefore reference node is missing

Angular's internal LView/TNode bookkeeping can get out of sync with the
real DOM: manual DOM manipulation, a browser extension, or an edge case in
Angular's own view-insertion/reordering code can all leave Angular believing
a node is still attached at a given position when it isn't. The next time
Angular's renderer calls `insertBefore` relative to that stale reference
node, the native DOM API throws an opaque `NotFoundError` with no indication
of which node or component was involved, making these errors effectively
undebuggable in production:

    NotFoundError: Failed to execute 'insertBefore' on 'Node': The node
    before which the new node is to be inserted is not a child of this node.
        at Node.insertBefore (native)
        at DefaultDomRenderer2.insertBefore (packages/platform-browser/src/dom/dom_renderer.ts)
        at nativeInsertBefore (packages/core/src/render3/dom_node_manipulation.ts)
        at nativeAppendOrInsertBefore (packages/core/src/render3/dom_node_manipulation.ts)
        ... (called while Angular inserts or moves a view during change detection)

Check the reference node's actual parent against the expected parent before
calling the native `insertBefore`, and throw a descriptive `RuntimeError`
(NG05106) instead, following the same pattern already used for hydration
node mismatches.

(cherry picked from commit 1cb3d606bf)
This commit is contained in:
arturovt
2026-08-13 19:02:27 +03:00
committed by Jessica Janiuk
parent 0cd243d99a
commit 640460d606
15 changed files with 153 additions and 0 deletions
@@ -0,0 +1,41 @@
# Insertion reference node not found
Angular threw this error because it tried to insert a DOM node next to another node it was tracking, and that node isn't where Angular expects it to be anymore.
Angular keeps track of the DOM nodes it renders so it knows where to insert, move, or remove things later, for example when a `@for` block re-renders. If something outside Angular changes that part of the DOM (removes a node, moves it somewhere else), Angular's internal record goes stale. The next time it tries to insert next to that node, you get this error instead of a confusing native `NotFoundError`.
This can happen because of:
- App code touching the DOM directly (`ElementRef.nativeElement`, `document.querySelector`, `innerHTML`, etc.) instead of going through Angular.
- A browser extension messing with the page, like a translation tool, a grammar checker, or a password manager.
- An edge case in Angular itself, in code that reorders or conditionally renders views (`@for`, `@if`, dynamically created views).
The following example triggers the error:
```angular-ts
@Component({
selector: 'app-example',
template: `@if (show) {
<span>{{ text }}</span>
}`,
})
export class Example {
show = true;
text = 'hello';
hostElement = inject(ElementRef).nativeElement;
ngAfterViewInit() {
// Removing this node behind Angular's back is what causes the error.
this.hostElement.querySelector('span').remove();
}
}
```
## Debugging the error
The error message tells you which node Angular expected to find, so start there.
- Look for code in this component (or a parent) that touches the DOM directly instead of using Angular APIs, and switch it to use those instead.
- If the error only shows up for some users, or you can reproduce it locally with an extension installed, try an incognito window with extensions turned off.
- If it happens consistently on the same route for many different users, it's probably not an extension (those vary per user) — look for something deterministic in that page instead, like a `@for`/`@if` that reorders or removes content in an unusual way, or a third-party widget embedded on that page.
- If none of the above explains it, please file an issue with a reproduction.
@@ -47,6 +47,7 @@
| `NG02825` | [Fetch response body exceeds the configured limit](errors/NG02825) |
| `NG05000` | [Hydration with unsupported Zone.js instance.](errors/NG05000) |
| `NG05104` | [Root element was not found.](errors/NG05104) |
| `NG05106` | [Insertion reference node not found](errors/NG05106) |
| `NG05703` | [Suspicious URL origin change during SSR](errors/NG05703) |
## Compiler errors
@@ -13,6 +13,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
HYDRATION_CONFLICTING_FEATURES = 5001,
// (undocumented)
INSERT_BEFORE_NODE_NOT_FOUND = -5106,
// (undocumented)
NO_PLUGIN_FOR_EVENT = -5101,
// (undocumented)
ROOT_NODE_NOT_FOUND = -5104,
@@ -0,0 +1,44 @@
/**
* @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 {Component, TemplateRef, ViewChild, ViewContainerRef} from '../../src/core';
import {TestBed} from '../../testing';
describe('DOM node manipulation outside of Angular', () => {
it('should throw a descriptive error when a tracked node was detached externally', () => {
@Component({
template: `
<ng-template #tpl><span>view</span></ng-template>
<div #container></div>
`,
})
class App {
@ViewChild('container', {read: ViewContainerRef, static: true})
container: ViewContainerRef = null!;
@ViewChild('tpl', {read: TemplateRef, static: true}) tpl: TemplateRef<any> = null!;
}
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const app = fixture.componentInstance;
// This view's first node will be used as the `refChild` for the next insert.
app.container.createEmbeddedView(app.tpl);
const span = fixture.nativeElement.querySelector('span') as HTMLElement;
// Pretend a browser extension (Grammarly, a password manager, etc.) removed it.
span.remove();
// Inserting a new view in front of it now has to insertBefore(span), and span
// isn't attached anymore.
expect(() => app.container.createEmbeddedView(app.tpl, {}, 0)).toThrowError(
/NG05106.*no longer a child/,
);
});
});
@@ -431,6 +431,7 @@
"defaultThrowError",
"delayChangeDetectionForEvents",
"deleteOrUnsetInMap",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -343,6 +343,7 @@
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -43,6 +43,7 @@
"createLinkElement",
"createProvidersConfig",
"createStyleElement",
"describeDomNode",
"errorHandler",
"getBaseElementHref",
"getDOM",
@@ -507,6 +507,7 @@
"defaultIterableDiffersFactory",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -504,6 +504,7 @@
"defaultIterableDiffersFactory",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -474,6 +474,7 @@
"defaultThrowError",
"deferBlockHasErrored",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -568,6 +568,7 @@
"defaultUrlMatcher",
"defer",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -315,6 +315,7 @@
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -353,6 +353,19 @@ class DefaultDomRenderer2 implements Renderer2 {
insertBefore(parent: any, newChild: any, refChild: any): void {
if (parent) {
const targetParent = isTemplateNode(parent) ? parent.content : parent;
// If something outside Angular removed or moved `refChild` (a browser extension, for
// example), the native call below throws a `NotFoundError` with no useful info. Catch it
// here so we can say what actually happened.
if (refChild != null && refChild.parentNode !== targetParent) {
throw new RuntimeError(
RuntimeErrorCode.INSERT_BEFORE_NODE_NOT_FOUND,
ngDevMode
? `Angular could not insert a node before ${describeDomNode(refChild)} because it is no longer a child of ${describeDomNode(targetParent)}. ` +
`This can happen when code outside of Angular's control (for example, a browser extension or a script that directly manipulates the DOM) ` +
`has moved or removed a node that Angular is still managing.`
: describeDomNode(refChild),
);
}
targetParent.insertBefore(newChild, refChild);
}
}
@@ -542,6 +555,13 @@ function isTemplateNode(node: any): node is HTMLTemplateElement {
return node.tagName === 'TEMPLATE' && node.content !== undefined;
}
// Short description of a node for error messages. Truncates text so a huge text node can't blow
// up the message, same idea as `shorten()` in core's `hydration/error_handling.ts`.
function describeDomNode(node: Node): string {
const textContent = node.textContent?.slice(0, 50);
return textContent ? `${node.nodeName} ("${textContent}")` : node.nodeName;
}
class ShadowDomRenderer extends DefaultDomRenderer2 {
private shadowRoot: any;
+1
View File
@@ -21,6 +21,7 @@ export const enum RuntimeErrorCode {
TESTABILITY_NOT_FOUND = 5103,
ROOT_NODE_NOT_FOUND = -5104,
UNEXPECTED_SYNTHETIC_PROPERTY = 5105,
INSERT_BEFORE_NODE_NOT_FOUND = -5106,
// Sanitization-related errors (5200-5300 range)
SANITIZATION_UNSAFE_SCRIPT = 5200,
@@ -181,6 +181,42 @@ describe('DefaultDomRendererV2', () => {
expect(otherChild.parentNode).toBe(template.content);
});
it('should be able to insert a child when `refChild` is `null`', () => {
const parent = document.createElement('div');
const child = document.createElement('div');
renderer.insertBefore(parent, child, null);
expect(child.parentNode).toBe(parent);
});
describe('when the reference node was detached outside of Angular', () => {
it('should throw a descriptive error instead of a native NotFoundError', () => {
const parent = document.createElement('div');
const refChild = document.createElement('span');
const newChild = document.createElement('div');
parent.appendChild(refChild);
// pretend something outside Angular removed it
refChild.remove();
expect(() => renderer.insertBefore(parent, newChild, refChild)).toThrowError(/NG05106/);
expect(newChild.parentNode).toBeNull();
});
it('should throw a descriptive error when the reference node was moved to another parent', () => {
const parent = document.createElement('div');
const otherParent = document.createElement('div');
const refChild = document.createElement('span');
const newChild = document.createElement('div');
parent.appendChild(refChild);
otherParent.appendChild(refChild);
expect(() => renderer.insertBefore(parent, newChild, refChild)).toThrowError(/NG05106/);
});
});
describe('should not cleanup styles of destroyed components when `REMOVE_STYLES_ON_COMPONENT_DESTROY` is `false`', () => {
beforeEach(() => {
TestBed.resetTestingModule();