fix(core): clean up dehydrated views during HMR component replacement

During HMR, `recreateLView()` destroys the old LView and removes its
DOM nodes, but never cleans up dehydrated view DOM nodes stored in
`LContainer[DEHYDRATED_VIEWS]`. These are SSR-rendered DOM nodes
preserved by Angular's hydration system. When the new view renders,
both the old dehydrated DOM and the new DOM coexist, causing visible
duplication (e.g. `<app-shell>` header/footer appearing twice).

Call `cleanupLView` from the hydration cleanup module after
`destroyLView` and before `removeViewFromDOM` to remove any remaining
dehydrated DOM nodes before the replacement view is rendered.

Fixes #66503
This commit is contained in:
Max Millien 2026-03-14 10:18:13 -04:00 committed by Matthew Beck (Berry)
parent 77d7378ffd
commit dc0446552a
3 changed files with 72 additions and 1 deletions

View file

@ -107,7 +107,7 @@ export function cleanupLContainer(lContainer: LContainer) {
* Walks over `LContainer`s and components registered within
* this LView and invokes dehydrated views cleanup function for each one.
*/
function cleanupLView(lView: LView) {
export function cleanupLView(lView: LView) {
cleanupI18nHydrationData(lView);
const tView = lView[TVIEW];

View file

@ -34,6 +34,7 @@ import {
TVIEW,
} from './interfaces/view';
import {assertTNodeType} from './node_assert';
import {cleanupLView as cleanupDehydratedLView} from '../hydration/cleanup';
import {destroyLView, removeViewFromDOM} from './node_manipulation';
import {RendererFactory} from './interfaces/renderer';
import {NgZone} from '../zone';
@ -279,6 +280,12 @@ function recreateLView(
// Destroy the detached LView.
destroyLView(lView[TVIEW], lView);
// Clean up any dehydrated views left over from SSR hydration.
// Neither destroyLView nor removeViewFromDOM handle DOM nodes
// stored in LContainer[DEHYDRATED_VIEWS], which causes duplicated
// content when the view is re-rendered during HMR.
cleanupDehydratedLView(lView);
// Always force the creation of a new renderer to ensure state captured during construction
// stays consistent with the new component definition by clearing any old ached factories.
const rendererFactory = lView[ENVIRONMENT].rendererFactory;

View file

@ -39,6 +39,11 @@ import {clearTranslations, loadTranslations} from '@angular/localize';
import {computeMsgId} from '@angular/compiler';
import {EVENT_MANAGER_PLUGINS} from '@angular/platform-browser';
import {ComponentType} from '../../src/render3';
import {getComponentLView} from '../../src/render3/util/discovery_utils';
import {DEHYDRATED_VIEWS} from '../../src/render3/interfaces/container';
import {HEADER_OFFSET, TVIEW} from '../../src/render3/interfaces/view';
import {isLContainer} from '../../src/render3/interfaces/type_checks';
import {NUM_ROOT_NODES} from '../../src/hydration/interfaces';
import {isNode} from '@angular/private/testing';
describe('hot module replacement', () => {
@ -2075,6 +2080,65 @@ describe('hot module replacement', () => {
});
});
it('should clean up dehydrated views from LContainers during HMR', () => {
const initialMetadata: Component = {
selector: 'child-cmp',
template: '@if (true) { <div>Initial</div> }',
};
@Component(initialMetadata)
class ChildCmp {}
@Component({
imports: [ChildCmp],
template: '<child-cmp/>',
})
class RootCmp {}
const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();
const childEl = fixture.nativeElement.querySelector('child-cmp')!;
expectHTML(fixture.nativeElement, '<child-cmp><div>Initial</div></child-cmp>');
// Simulate SSR dehydrated views by injecting fake dehydrated DOM nodes
// into the LContainer's DEHYDRATED_VIEWS slot. During SSR hydration,
// Angular stores references to server-rendered DOM in this slot.
const childLView = getComponentLView(childEl);
const tView = childLView[TVIEW];
// Create fake dehydrated DOM content that simulates SSR remnants.
// Insert before existing content so the node has a nextSibling,
// which removeDehydratedView validates in dev mode.
const dehydratedNode = document.createElement('div');
dehydratedNode.textContent = 'SSR ghost';
childEl.insertBefore(dehydratedNode, childEl.firstChild);
// Find the LContainer created by the @if and inject dehydrated views.
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
if (isLContainer(childLView[i])) {
childLView[i][DEHYDRATED_VIEWS] = [
{firstChild: dehydratedNode, data: {[NUM_ROOT_NODES]: 1}},
];
break;
}
}
// Verify the dehydrated node is present in the DOM.
expect(childEl.innerHTML).toContain('SSR ghost');
// Trigger HMR replacement.
replaceMetadata(ChildCmp, {
...initialMetadata,
template: '@if (true) { <div>Replaced</div> }',
});
fixture.detectChanges();
// After HMR, dehydrated DOM nodes should have been cleaned up — no duplication.
expect(childEl.innerHTML).not.toContain('SSR ghost');
expectHTML(fixture.nativeElement, '<child-cmp><div>Replaced</div></child-cmp>');
});
// Testing utilities
// Field that we'll monkey-patch onto DOM elements that were created