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();