diff --git a/adev/src/content/reference/errors/NG05106.md b/adev/src/content/reference/errors/NG05106.md
new file mode 100644
index 00000000000..6dc9ccc9ac2
--- /dev/null
+++ b/adev/src/content/reference/errors/NG05106.md
@@ -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) {
+ {{ text }}
+ }`,
+})
+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.
diff --git a/adev/src/content/reference/errors/overview.md b/adev/src/content/reference/errors/overview.md
index 58bfa16088d..5b4dec3543a 100644
--- a/adev/src/content/reference/errors/overview.md
+++ b/adev/src/content/reference/errors/overview.md
@@ -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
diff --git a/goldens/public-api/platform-browser/errors.api.md b/goldens/public-api/platform-browser/errors.api.md
index 0d7a720700f..00aaf47147e 100644
--- a/goldens/public-api/platform-browser/errors.api.md
+++ b/goldens/public-api/platform-browser/errors.api.md
@@ -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,
diff --git a/packages/core/test/acceptance/dom_node_manipulation_spec.ts b/packages/core/test/acceptance/dom_node_manipulation_spec.ts
new file mode 100644
index 00000000000..9983819da2d
--- /dev/null
+++ b/packages/core/test/acceptance/dom_node_manipulation_spec.ts
@@ -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: `
+ view
+
+ `,
+ })
+ class App {
+ @ViewChild('container', {read: ViewContainerRef, static: true})
+ container: ViewContainerRef = null!;
+
+ @ViewChild('tpl', {read: TemplateRef, static: true}) tpl: TemplateRef = 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/,
+ );
+ });
+});
diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json
index e676e2c62d4..54cd560acc3 100644
--- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json
@@ -431,6 +431,7 @@
"defaultThrowError",
"delayChangeDetectionForEvents",
"deleteOrUnsetInMap",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json
index 6fa3a7cdde3..4f747dc0989 100644
--- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json
@@ -343,6 +343,7 @@
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json
index 10972b74c69..f119ff5f0b4 100644
--- a/packages/core/test/bundling/defer/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json
@@ -43,6 +43,7 @@
"createLinkElement",
"createProvidersConfig",
"createStyleElement",
+ "describeDomNode",
"errorHandler",
"getBaseElementHref",
"getDOM",
diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json
index f4128fd8914..ea1786afdd6 100644
--- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json
@@ -507,6 +507,7 @@
"defaultIterableDiffersFactory",
"defaultThrowError",
"delayChangeDetectionForEvents",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json
index a2ea5f25415..b3e44d84dae 100644
--- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json
@@ -504,6 +504,7 @@
"defaultIterableDiffersFactory",
"defaultThrowError",
"delayChangeDetectionForEvents",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json
index a5b2426590d..d0b6ed54c0d 100644
--- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json
@@ -474,6 +474,7 @@
"defaultThrowError",
"deferBlockHasErrored",
"delayChangeDetectionForEvents",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json
index b3dc5150925..403c184cff3 100644
--- a/packages/core/test/bundling/router/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/router/bundle.golden_symbols.json
@@ -568,6 +568,7 @@
"defaultUrlMatcher",
"defer",
"delayChangeDetectionForEvents",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json
index 5455e4f8ae0..35f8283fd04 100644
--- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json
+++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json
@@ -315,6 +315,7 @@
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
+ "describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts
index c04a679bb35..fa212895d3b 100644
--- a/packages/platform-browser/src/dom/dom_renderer.ts
+++ b/packages/platform-browser/src/dom/dom_renderer.ts
@@ -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;
diff --git a/packages/platform-browser/src/errors.ts b/packages/platform-browser/src/errors.ts
index 97e17883c99..7836ca71384 100644
--- a/packages/platform-browser/src/errors.ts
+++ b/packages/platform-browser/src/errors.ts
@@ -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,
diff --git a/packages/platform-browser/test/dom/dom_renderer_spec.ts b/packages/platform-browser/test/dom/dom_renderer_spec.ts
index 2b076308a6c..47945c96856 100644
--- a/packages/platform-browser/test/dom/dom_renderer_spec.ts
+++ b/packages/platform-browser/test/dom/dom_renderer_spec.ts
@@ -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();