Skip to content

Commit dc04465

Browse files
maxathymattrbeck
authored andcommitted
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
1 parent 77d7378 commit dc04465

File tree

3 files changed

+72
-1
lines changed

3 files changed

+72
-1
lines changed

packages/core/src/hydration/cleanup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function cleanupLContainer(lContainer: LContainer) {
107107
* Walks over `LContainer`s and components registered within
108108
* this LView and invokes dehydrated views cleanup function for each one.
109109
*/
110-
function cleanupLView(lView: LView) {
110+
export function cleanupLView(lView: LView) {
111111
cleanupI18nHydrationData(lView);
112112

113113
const tView = lView[TVIEW];

packages/core/src/render3/hmr.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
TVIEW,
3535
} from './interfaces/view';
3636
import {assertTNodeType} from './node_assert';
37+
import {cleanupLView as cleanupDehydratedLView} from '../hydration/cleanup';
3738
import {destroyLView, removeViewFromDOM} from './node_manipulation';
3839
import {RendererFactory} from './interfaces/renderer';
3940
import {NgZone} from '../zone';
@@ -279,6 +280,12 @@ function recreateLView(
279280
// Destroy the detached LView.
280281
destroyLView(lView[TVIEW], lView);
281282

283+
// Clean up any dehydrated views left over from SSR hydration.
284+
// Neither destroyLView nor removeViewFromDOM handle DOM nodes
285+
// stored in LContainer[DEHYDRATED_VIEWS], which causes duplicated
286+
// content when the view is re-rendered during HMR.
287+
cleanupDehydratedLView(lView);
288+
282289
// Always force the creation of a new renderer to ensure state captured during construction
283290
// stays consistent with the new component definition by clearing any old ached factories.
284291
const rendererFactory = lView[ENVIRONMENT].rendererFactory;

packages/core/test/acceptance/hmr_spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import {clearTranslations, loadTranslations} from '@angular/localize';
3939
import {computeMsgId} from '@angular/compiler';
4040
import {EVENT_MANAGER_PLUGINS} from '@angular/platform-browser';
4141
import {ComponentType} from '../../src/render3';
42+
import {getComponentLView} from '../../src/render3/util/discovery_utils';
43+
import {DEHYDRATED_VIEWS} from '../../src/render3/interfaces/container';
44+
import {HEADER_OFFSET, TVIEW} from '../../src/render3/interfaces/view';
45+
import {isLContainer} from '../../src/render3/interfaces/type_checks';
46+
import {NUM_ROOT_NODES} from '../../src/hydration/interfaces';
4247
import {isNode} from '@angular/private/testing';
4348

4449
describe('hot module replacement', () => {
@@ -2075,6 +2080,65 @@ describe('hot module replacement', () => {
20752080
});
20762081
});
20772082

2083+
it('should clean up dehydrated views from LContainers during HMR', () => {
2084+
const initialMetadata: Component = {
2085+
selector: 'child-cmp',
2086+
template: '@if (true) { <div>Initial</div> }',
2087+
};
2088+
2089+
@Component(initialMetadata)
2090+
class ChildCmp {}
2091+
2092+
@Component({
2093+
imports: [ChildCmp],
2094+
template: '<child-cmp/>',
2095+
})
2096+
class RootCmp {}
2097+
2098+
const fixture = TestBed.createComponent(RootCmp);
2099+
fixture.detectChanges();
2100+
2101+
const childEl = fixture.nativeElement.querySelector('child-cmp')!;
2102+
expectHTML(fixture.nativeElement, '<child-cmp><div>Initial</div></child-cmp>');
2103+
2104+
// Simulate SSR dehydrated views by injecting fake dehydrated DOM nodes
2105+
// into the LContainer's DEHYDRATED_VIEWS slot. During SSR hydration,
2106+
// Angular stores references to server-rendered DOM in this slot.
2107+
const childLView = getComponentLView(childEl);
2108+
const tView = childLView[TVIEW];
2109+
2110+
// Create fake dehydrated DOM content that simulates SSR remnants.
2111+
// Insert before existing content so the node has a nextSibling,
2112+
// which removeDehydratedView validates in dev mode.
2113+
const dehydratedNode = document.createElement('div');
2114+
dehydratedNode.textContent = 'SSR ghost';
2115+
childEl.insertBefore(dehydratedNode, childEl.firstChild);
2116+
2117+
// Find the LContainer created by the @if and inject dehydrated views.
2118+
for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) {
2119+
if (isLContainer(childLView[i])) {
2120+
childLView[i][DEHYDRATED_VIEWS] = [
2121+
{firstChild: dehydratedNode, data: {[NUM_ROOT_NODES]: 1}},
2122+
];
2123+
break;
2124+
}
2125+
}
2126+
2127+
// Verify the dehydrated node is present in the DOM.
2128+
expect(childEl.innerHTML).toContain('SSR ghost');
2129+
2130+
// Trigger HMR replacement.
2131+
replaceMetadata(ChildCmp, {
2132+
...initialMetadata,
2133+
template: '@if (true) { <div>Replaced</div> }',
2134+
});
2135+
fixture.detectChanges();
2136+
2137+
// After HMR, dehydrated DOM nodes should have been cleaned up — no duplication.
2138+
expect(childEl.innerHTML).not.toContain('SSR ghost');
2139+
expectHTML(fixture.nativeElement, '<child-cmp><div>Replaced</div></child-cmp>');
2140+
});
2141+
20782142
// Testing utilities
20792143

20802144
// Field that we'll monkey-patch onto DOM elements that were created

0 commit comments

Comments
 (0)