fix(core): throw coded RuntimeErrors instead of crashing when hydration/rendering can't find an expected DOM node

This started from a real production crash trace:

    TypeError: Cannot read properties of null (reading 'nextSibling')

Traced through a minified bundle back to siblingAfter() in
packages/core/src/hydration/node_lookup_utils.ts. It walks forward a
fixed number of DOM siblings during hydration, based on how many the
server rendered. Its only guard, validateSiblingNodeExists(), is
gated behind ngDevMode and stripped from production builds. If the
client DOM has fewer real siblings than the server-serialized data
expects — client/server render diverged, or something outside
Angular (a browser extension, an injected script) removed a node —
the loop ran fully unguarded in production: currentNode went null
partway through, and the next iteration's currentNode.nextSibling
threw a raw, uncoded TypeError instead of a coded, debuggable one.

Reproduced first, not just theorized: added a test that server-
renders a @for loop with 3 items, removes 2 of them before hydration
runs, and confirmed it actually throws that exact TypeError against
the original code before touching anything.

Fix: add a null check at the top of the loop, matching the pattern
already used for the existing NG0500/NG0502 hydration checks in
element.ts — the dev-mode check runs first as before (a no-op in
production), and the new check is a pure safety net that only fires
once that dev-mode check has already been compiled away. In dev mode
this is provably dead code, so nothing about dev behavior changes.
Coded as NG0501 (HYDRATION_MISSING_SIBLINGS, already existed).

While investigating, found and fixed two more call sites with the
exact same shape of bug — a dev-only check that leaves production
completely unguarded:

- navigateToNode() (same file): walks an explicit "firstChild /
  nextSibling" navigation path recorded for nodes that can't rely on
  simple sibling-walking (e.g. content right after an <ng-content>
  slot). Its only guard was also ngDevMode-only. Added the same
  production safety net, both mid-loop (the raw-crash case) and
  post-loop (where it could previously return null silently despite
  the function's return type promising a non-null RNode). New code:
  NG0509 (HYDRATION_MISSING_NODE_ON_PATH).

- getParentRElement() (render3/node_manipulation.ts): typed its
  tNode parameter as always non-null, but a real production trace
  showed it can be null at runtime, crashing on tNode.parent with no
  useful information. Widened the type to TNode | null and added a
  guard that throws a coded error instead. New code: NG0510
  (PARENT_NODE_NOT_FOUND).

  Unlike the other two, this one isn't gated behind ngDevMode at all
  in the original code — it reproduces unconditionally. Decoded the
  actual production minified stack trace against this source
  (matched every frame character-for-character) to find the real
  cause instead of guessing: an @if/@switch branch's content is its
  own embedded template with its own TView, built lazily the first
  time that branch renders. If an error interrupts that first pass —
  here, a hydration mismatch on the branch's second child, after the
  first child's TNode was already created — TView.firstCreatePass
  still gets flipped to false in render.ts's `catch` block before the
  error propagates, permanently marking that TView as corrupted.
  Unlike a component's TView (rebuilt from scratch next time via
  getOrCreateComponentTView()'s incompleteFirstPass check), nothing
  rebuilds an embedded view's TView. The next time that exact branch
  is selected again, its instructions read straight from the
  corrupted tView.data instead of creating a fresh TNode, and any
  node past the interruption point is still null.

  The real test added for this (full_app_hydration_spec.ts) exercises
  that whole chain for real: a hydration mismatch on an @if branch's
  second child, then re-entering the same branch. Confirmed it throws
  the exact byte-for-byte production error message ("Cannot read
  properties of null (reading 'parent')") without the fix, and the
  coded NG0510 with it. This replaces an earlier synthetic unit test
  that just cast a variable to null to bypass the type system rather
  than reproducing the actual failure.

  Note: a more thorough fix would rebuild the corrupted embedded TView
  itself (mirroring getOrCreateComponentTView()), which does make the
  crash disappear — but doing that surfaces a second, separate bug:
  the aborted first attempt's partially-created LView/DOM is never
  torn down, so the retried branch's content gets appended alongside
  leftover orphaned nodes instead of replacing them, corrupting
  content silently instead of crashing loudly. That's a materially
  bigger fix (proper LView cleanup after a mid-creation exception) and
  is intentionally out of scope here; this commit keeps the narrower,
  safe fix (a clear coded error) rather than trading a loud crash for
  silent data corruption.

Along the way, deduplicated describeDomNode(), which existed as two
near-identical private copies (one in this file's own NG0500 check,
one in platform-browser's dom_renderer.ts insertBefore check) and
moved it into core's hydration/error_handling.ts, shared via the
private ɵdescribeDomNode export.

That function was then removed from every production code path
entirely, per review feedback: computing a human-readable node
description is debug-oriented work that shouldn't ship unconditionally
in production bundles, regardless of how cheap it is — production
should get the bare coded error only, the same as everywhere else in
this file. All three fixes above follow that: ngDevMode && 'message'
in production, no computed description. Confirmed the removal
actually shrinks output, not just assumed it: regenerated all 8
affected bundling symbol goldens, and each one shows exactly one
symbol removed — "describeDomNode" — and nothing else.
This commit is contained in:
arturovt
2026-08-19 09:40:48 +03:00
committed by Leon Senft
parent 9c52dbf216
commit 83f7695b2e
17 changed files with 231 additions and 37 deletions
+4
View File
@@ -62,6 +62,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
HYDRATION_MISSING_NODE = -502,
// (undocumented)
HYDRATION_MISSING_NODE_ON_PATH = 509,
// (undocumented)
HYDRATION_MISSING_SIBLINGS = -501,
// (undocumented)
HYDRATION_NODE_MISMATCH = -500,
@@ -146,6 +148,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
OUTPUT_REF_DESTROYED = 953,
// (undocumented)
PARENT_NODE_NOT_FOUND = 510,
// (undocumented)
PIPE_NOT_FOUND = -302,
// (undocumented)
PLATFORM_ALREADY_DESTROYED = 404,
+1
View File
@@ -96,6 +96,7 @@ export {
withIncrementalHydration as ɵwithIncrementalHydration,
} from './hydration/api';
export {CACHE_ACTIVE as ɵCACHE_ACTIVE} from './hydration/cache';
export {describeDomNode as ɵdescribeDomNode} from './hydration/error_handling';
export {withEventReplay as ɵwithEventReplay} from './hydration/event_replay';
export {resetIncrementalHydrationRuntimeForTests as ɵresetIncrementalHydrationRuntimeForTests} from './hydration/incremental_runtime';
export {
+2
View File
@@ -89,6 +89,8 @@ export const enum RuntimeErrorCode {
HYDRATION_STABLE_TIMEDOUT = -506,
MISSING_SSR_CONTENT_INTEGRITY_MARKER = -507,
MISCONFIGURED_INCREMENTAL_HYDRATION = 508,
HYDRATION_MISSING_NODE_ON_PATH = 509,
PARENT_NODE_NOT_FOUND = 510,
// Signal Errors
SIGNAL_WRITE_FROM_ILLEGAL_CONTEXT = 600,
+12 -1
View File
@@ -244,7 +244,7 @@ export function invalidSkipHydrationHost(rNode: RNode): Error {
function stringifyTNodeAttrs(tNode: TNode): string {
const results = [];
if (tNode.attrs) {
for (let i = 0; i < tNode.attrs.length; ) {
for (let i = 0; i < tNode.attrs.length;) {
const attrName = tNode.attrs[i++];
// Once we reach the first flag, we know that the list of
// attributes is over.
@@ -490,3 +490,14 @@ function shorten(input: string | null, maxLength = 50): string {
input = stripNewlines(input);
return input.length > maxLength ? `${input.substring(0, maxLength - 1)}…` : input;
}
/**
* Cheap, production-safe description of a DOM node for error messages: its `nodeName`, plus a
* truncated snippet of its `textContent` if it has any. Unlike `shorten()` above, this is meant
* to be called from code paths that also run in production, so it skips the newline-stripping
* and ellipsis formatting that `shorten()` does.
*/
export function describeDomNode(node: Node): string {
const textContent = node.textContent?.slice(0, 50);
return textContent ? `${node.nodeName} ("${textContent}")` : node.nodeName;
}
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {TNode, TNodeType} from '../render3/interfaces/node';
import {RElement, RNode} from '../render3/interfaces/renderer_dom';
import {
@@ -184,6 +185,16 @@ export function siblingAfter<T extends RNode>(skip: number, from: RNode): T | nu
let currentNode = from;
for (let i = 0; i < skip; i++) {
ngDevMode && validateSiblingNodeExists(currentNode);
// `validateSiblingNodeExists` above would normally catch a missing sibling too, but it's
// dev-mode only. Guard against it here so production throws a coded RuntimeError instead
// of a raw TypeError when dereferencing `currentNode` below.
if (currentNode == null) {
throw new RuntimeError(
RuntimeErrorCode.HYDRATION_MISSING_SIBLINGS,
ngDevMode &&
'During hydration Angular expected more sibling nodes to be present. This usually means the client-rendered DOM no longer matches the server-rendered HTML.',
);
}
currentNode = currentNode.nextSibling!;
}
return currentNode as T;
@@ -219,6 +230,16 @@ function navigateToNode(from: Node, instructions: (number | NodeNavigationStep)[
if (ngDevMode && !node) {
throw nodeNotFoundAtPathError(from, stringifyNavigationInstructions(instructions));
}
// `nodeNotFoundAtPathError` above would normally catch a missing node too, but it's
// dev-mode only. Guard against it here so production throws a coded RuntimeError instead
// of a raw TypeError when dereferencing `node` below.
if (!node) {
throw new RuntimeError(
RuntimeErrorCode.HYDRATION_MISSING_NODE_ON_PATH,
ngDevMode &&
'During hydration Angular was unable to locate a node using a recorded navigation path. This usually means the client-rendered DOM no longer matches the server-rendered HTML.',
);
}
switch (step) {
case NODE_NAVIGATION_STEP_FIRST_CHILD:
node = node.firstChild!;
@@ -232,6 +253,17 @@ function navigateToNode(from: Node, instructions: (number | NodeNavigationStep)[
if (ngDevMode && !node) {
throw nodeNotFoundAtPathError(from, stringifyNavigationInstructions(instructions));
}
// Same as above: `node` can legitimately end up null here (the path's last step ran off
// the end of the real DOM) without the loop ever dereferencing it further, so the raw
// TypeError guard above never gets a chance to fire for this case. Catch it here instead,
// since this function's return type promises a non-null `RNode`.
if (!node) {
throw new RuntimeError(
RuntimeErrorCode.HYDRATION_MISSING_NODE_ON_PATH,
ngDevMode &&
'During hydration Angular was unable to locate a node using a recorded navigation path. This usually means the client-rendered DOM no longer matches the server-rendered HTML.',
);
}
return node as RNode;
}
@@ -8,6 +8,7 @@
import {RuntimeError, RuntimeErrorCode} from '../../errors';
import {
describeDomNode,
invalidSkipHydrationHost,
validateMatchingNode,
validateNodeExists,
@@ -376,19 +377,12 @@ function locateOrCreateElementNodeImpl(
// `hasSkipHydrationAttrOnRElement` below calls `.hasAttribute`, which needs `native` to be
// an Element. `validateMatchingNode` above would normally catch a wrong node type, but it's
// dev-mode only. Guard against it here too, cheaply, so production throws a coded
// RuntimeError instead of a raw TypeError. In production, skip the full sentence and just
// include the mismatched node's `nodeName`/`textContent` so the error stays cheap to build.
// RuntimeError instead of a raw TypeError.
if ((native as unknown as Node).nodeType !== Node.ELEMENT_NODE) {
const node = native as unknown as Node;
// Truncate so a large text node can't blow up the error message, same as
// `shorten()` in `hydration/error_handling.ts`.
const textContent = node.textContent && node.textContent.slice(0, 50);
const description = textContent ? `${node.nodeName} ("${textContent}")` : node.nodeName;
throw new RuntimeError(
RuntimeErrorCode.HYDRATION_NODE_MISMATCH,
ngDevMode
? `During hydration Angular expected an element at this location, but found a ${description} node instead.`
: description,
ngDevMode &&
`During hydration Angular expected an element at this location, but found a ${describeDomNode(native as unknown as Node)} node instead.`,
);
}
+25 -1
View File
@@ -8,6 +8,7 @@
import {consumerDestroy, setActiveConsumer} from '../../primitives/signals';
import {RuntimeError, RuntimeErrorCode} from '../errors';
import {NotificationSource} from '../change_detection/scheduling/zoneless_scheduling';
import {hasInSkipHydrationBlockFlag} from '../hydration/skip_hydration';
import {ViewEncapsulation} from '../metadata/view';
@@ -486,7 +487,30 @@ function executeOnDestroys(tView: TView, lView: LView): void {
* @param tNode: `TNode` for which we wish to retrieve render parent.
* @param lView: Current `LView`.
*/
export function getParentRElement(tView: TView, tNode: TNode, lView: LView): RElement | null {
export function getParentRElement(
tView: TView,
tNode: TNode | null,
lView: LView,
): RElement | null {
// `tNode` can genuinely be null here, not just as a defensive type-widening measure. An
// `@if`/`@switch` branch's content is its own embedded view with its own `TView`, built the
// first time that branch is rendered. If an error interrupts that first creation pass (for
// example, a hydration mismatch on one of the branch's later nodes, after an earlier node's
// `TNode` was already created), `TView.firstCreatePass` still gets flipped to `false` before
// the error propagates (see `renderView()`'s `catch` block in `instructions/render.ts`),
// permanently marking the view's `TNode` data as "already created" even though it isn't.
// Unlike a component's `TView`, nothing rebuilds an embedded view's `TView` afterward, so the
// next time that same branch is selected, its instructions read directly from the still-null
// slot in `tView.data` instead of creating a fresh `TNode` — which is what surfaces here.
// Guard against it so production throws a coded RuntimeError instead of a raw TypeError when
// dereferencing `tNode.parent` below.
if (tNode === null) {
throw new RuntimeError(
RuntimeErrorCode.PARENT_NODE_NOT_FOUND,
ngDevMode &&
'getParentRElement() was called with a null TNode, so no parent element could be resolved. This usually means a TNode was never created for this node, or was already destroyed.',
);
}
return getClosestRElement(tView, tNode.parent, lView);
}
@@ -431,7 +431,6 @@
"defaultThrowError",
"delayChangeDetectionForEvents",
"deleteOrUnsetInMap",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -343,7 +343,6 @@
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -43,7 +43,6 @@
"createLinkElement",
"createProvidersConfig",
"createStyleElement",
"describeDomNode",
"errorHandler",
"getBaseElementHref",
"getDOM",
@@ -507,7 +507,6 @@
"defaultIterableDiffersFactory",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -504,7 +504,6 @@
"defaultIterableDiffersFactory",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -475,7 +475,6 @@
"defaultThrowError",
"deferBlockHasErrored",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -568,7 +568,6 @@
"defaultUrlMatcher",
"defer",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -315,7 +315,6 @@
"defaultErrorHandler",
"defaultThrowError",
"delayChangeDetectionForEvents",
"describeDomNode",
"destroyLView",
"destroyViewTree",
"detachMovedView",
@@ -20,6 +20,7 @@ import {
RendererStyleFlags2,
RendererType2,
ViewEncapsulation,
ɵdescribeDomNode as describeDomNode,
ɵRuntimeError as RuntimeError,
type ListenerOptions,
ɵTracingService as TracingService,
@@ -359,11 +360,10 @@ class DefaultDomRenderer2 implements Renderer2 {
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),
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.`,
);
}
targetParent.insertBefore(newChild, refChild);
@@ -555,13 +555,6 @@ 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;
@@ -6313,12 +6313,11 @@ describe('platform-server full application hydration integration', () => {
} catch (e: unknown) {
const error = e as Error;
// This is the fixed behavior: a coded NG0500 RuntimeError, not a raw
// TypeError. Unlike other production RuntimeErrors, this one keeps a
// minimal message (the mismatched node's `nodeName` and `textContent`)
// rather than an empty one, since it's cheap (no DOM-printing machinery)
// and helps debugging a hydration mismatch that's otherwise invisible in prod.
// TypeError. Production intentionally gets a bare code with no
// description — building one (even a cheap one) is debug-only work
// that shouldn't ship unconditionally in production bundles.
expect(error instanceof TypeError).toBe(false);
expect(error.message).toBe('NG0500: #text ("Not an element anymore!")');
expect(error.message).toBe('NG0500');
expect(error.message).not.toContain('hasAttribute is not a function');
} finally {
(globalThis as any).ngDevMode = previousNgDevMode;
@@ -6393,6 +6392,148 @@ describe('platform-server full application hydration integration', () => {
},
);
it(
'should throw a coded RuntimeError, not a raw TypeError, when siblingAfter runs out ' +
'of DOM siblings to skip in production mode (ngDevMode off)',
async () => {
// Regression test. `siblingAfter` walks forward a fixed number of DOM siblings
// (based on the server-serialized container size) to find a container's anchor
// comment node. The dev-mode check that would normally catch a missing sibling
// (`validateSiblingNodeExists`) is gated behind `ngDevMode`, so it's removed from
// production builds. If the client DOM has fewer real siblings than the server
// expected (here, two of the three `@for`-rendered items are removed before
// hydration runs), production keeps walking past the end of the DOM and throws a
// raw TypeError instead of a coded RuntimeError.
@Component({
selector: 'app',
template: `
<div id="abc">
@for (item of items; track item) {
<p>{{ item }}</p>
}
</div>
`,
})
class SimpleComponent {
items = [1, 2, 3];
private doc = inject(DOCUMENT);
private isServer = isPlatformServer(inject(PLATFORM_ID));
ngAfterViewInit() {
// Only change the DOM on the server, right before it gets serialized. The
// serialized container size still says "3 items", but the DOM hydration
// actually sees will only have 1.
if (this.isServer) {
const items = this.doc.querySelectorAll('#abc p');
items[1]?.remove();
items[2]?.remove();
}
}
}
const html = await ssr(SimpleComponent);
const ssrContents = getAppContents(html);
expect(ssrContents).toContain('<app ngh');
resetTViewsFor(SimpleComponent);
// Simulate a production build. `siblingAfter` only calls
// `validateSiblingNodeExists` when `ngDevMode` is truthy, so turning it off here
// means the walk runs fully unguarded.
const previousNgDevMode = (globalThis as any).ngDevMode;
(globalThis as any).ngDevMode = false;
try {
await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [withNoopErrorHandler()],
});
fail('Expected the hydration process to throw.');
} catch (e: unknown) {
const error = e as Error;
// This is the fixed behavior: a coded NG0501 RuntimeError, not a raw TypeError.
// Production intentionally gets a bare code with no description.
expect(error instanceof TypeError).toBe(false);
expect(error.message).toBe('NG0501');
expect(error.message).not.toContain("reading 'nextSibling'");
} finally {
(globalThis as any).ngDevMode = previousNgDevMode;
}
},
);
it(
'should throw a coded RuntimeError, not a raw TypeError, when an @if branch is ' +
're-entered after a hydration mismatch corrupted its template on the first pass',
async () => {
// Regression test for getParentRElement() (node_manipulation.ts) receiving a null
// TNode. Unlike the siblingAfter/navigateToNode bugs above, this one isn't gated
// behind ngDevMode at all in the original code — it reproduces every time, with no
// need to simulate a production build.
//
// The mechanism: an @if/@switch branch's content is its own embedded template, with
// its own TView, built lazily the first time that branch is actually rendered
// (renderView() in render.ts). If an error is thrown partway through that *first*
// pass — here, a hydration node-mismatch on the branch's *second* child, so the
// first child's TNode is created but the second's never is — TView.firstCreatePass
// still gets flipped to false in render.ts's `catch` block before the error
// propagates, and the TView is marked `incompleteFirstPass`. Unlike a *component's*
// TView (which gets rebuilt from scratch next time via
// getOrCreateComponentTView()'s `incompleteFirstPass` check), nothing rebuilds an
// *embedded view's* TView. So the next time this exact branch is selected again —
// here, by flipping the condition off and back on — its instructions see
// `!tView.firstCreatePass` and read straight from `tView.data[slot]` instead of
// creating a fresh TNode, and the second child's slot is still null from the
// interrupted first pass.
let instance!: SimpleComponent;
@Component({
selector: 'app',
template: `
@if (cond()) {
<span>first</span>
<span>{{ text() }}</span>
} @else {
<span>else</span>
}
`,
})
class SimpleComponent {
cond = signal(true);
text = signal('orig');
private doc = inject(DOCUMENT);
constructor() {
instance = this;
}
ngAfterViewInit() {
// Swap the second <span> for a <div>, bypassing Angular's own node tracking
// (mimics a third-party script mutating the DOM, like the test above), so
// hydration hits a tag mismatch on the branch's SECOND node specifically,
// interrupting its first creation pass after the first node already succeeded.
const spans = this.doc.querySelectorAll('app span');
if (spans.length >= 2) {
const bad = this.doc.createElement('div');
bad.textContent = spans[1].textContent;
spans[1].replaceWith(bad);
}
}
}
const html = await ssr(SimpleComponent);
resetTViewsFor(SimpleComponent);
const appRef = await prepareEnvironmentAndHydrate(doc, html, SimpleComponent, {
envProviders: [withNoopErrorHandler()],
});
// Re-enter the same @if branch: leave it, then come back. This is what exposes the
// corrupted TView from the interrupted first pass above.
instance.cond.set(false);
appRef.tick();
instance.cond.set(true);
expect(() => appRef.tick()).toThrowError(/NG0510/);
},
);
it('should if there are any third-party scripts that manipulate the DOM', async () => {
@Component({
selector: 'app',