mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user