mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
77d7378ffd
commit
dc0446552a
3 changed files with 72 additions and 1 deletions
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue