diff --git a/packages/core/src/hydration/cleanup.ts b/packages/core/src/hydration/cleanup.ts index 861c58791ef..b148c3bb3df 100644 --- a/packages/core/src/hydration/cleanup.ts +++ b/packages/core/src/hydration/cleanup.ts @@ -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]; diff --git a/packages/core/src/render3/hmr.ts b/packages/core/src/render3/hmr.ts index 2d6dd1eee3e..042744b8288 100644 --- a/packages/core/src/render3/hmr.ts +++ b/packages/core/src/render3/hmr.ts @@ -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; diff --git a/packages/core/test/acceptance/hmr_spec.ts b/packages/core/test/acceptance/hmr_spec.ts index 8c7d8cac7c8..602d8eaaac9 100644 --- a/packages/core/test/acceptance/hmr_spec.ts +++ b/packages/core/test/acceptance/hmr_spec.ts @@ -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) {
Initial
}', + }; + + @Component(initialMetadata) + class ChildCmp {} + + @Component({ + imports: [ChildCmp], + template: '', + }) + class RootCmp {} + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + const childEl = fixture.nativeElement.querySelector('child-cmp')!; + expectHTML(fixture.nativeElement, '
Initial
'); + + // 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) {
Replaced
}', + }); + fixture.detectChanges(); + + // After HMR, dehydrated DOM nodes should have been cleaned up — no duplication. + expect(childEl.innerHTML).not.toContain('SSR ghost'); + expectHTML(fixture.nativeElement, '
Replaced
'); + }); + // Testing utilities // Field that we'll monkey-patch onto DOM elements that were created