Skip to content

Commit 357795c

Browse files
crisbetopkozlowski-opensource
authored andcommitted
fix(core): run HMR replacement in the zone (#59562)
Fixes that in some cases the HMR replacement function was being run outside the zone which meant that change detection would break down after a replacement. Fixes #59559. PR Close #59562
1 parent eb0b185 commit 357795c

File tree

2 files changed

+90
-36
lines changed

2 files changed

+90
-36
lines changed

packages/core/src/render3/hmr.ts

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
FLAGS,
3232
HEADER_OFFSET,
3333
HOST,
34+
INJECTOR,
3435
LView,
3536
LViewFlags,
3637
NEXT,
@@ -41,6 +42,7 @@ import {
4142
import {assertTNodeType} from './node_assert';
4243
import {destroyLView, removeViewFromDOM} from './node_manipulation';
4344
import {RendererFactory} from './interfaces/renderer';
45+
import {NgZone} from '../zone';
4446

4547
/**
4648
* Replaces the metadata of a component type and re-renders all live instances of the component.
@@ -149,51 +151,60 @@ function recreateLView(
149151
const tNode = lView[T_HOST] as TElementNode;
150152
ngDevMode && assertTNodeType(tNode, TNodeType.Element);
151153
ngDevMode && assertNotEqual(newDef, oldDef, 'Expected different component definition');
154+
const zone = lView[INJECTOR].get(NgZone, null);
155+
const recreate = () => {
156+
// Recreate the TView since the template might've changed.
157+
const newTView = getOrCreateComponentTView(newDef);
152158

153-
// Recreate the TView since the template might've changed.
154-
const newTView = getOrCreateComponentTView(newDef);
159+
// Always force the creation of a new renderer to ensure state captured during construction
160+
// stays consistent with the new component definition by clearing any old cached factories.
161+
const rendererFactory = lView[ENVIRONMENT].rendererFactory;
162+
clearRendererCache(rendererFactory, oldDef);
155163

156-
// Always force the creation of a new renderer to ensure state captured during construction
157-
// stays consistent with the new component definition by clearing any old cached factories.
158-
const rendererFactory = lView[ENVIRONMENT].rendererFactory;
159-
clearRendererCache(rendererFactory, oldDef);
164+
// Create a new LView from the new TView, but reusing the existing TNode and DOM node.
165+
const newLView = createLView(
166+
parentLView,
167+
newTView,
168+
instance,
169+
getInitialLViewFlagsFromDef(newDef),
170+
host,
171+
tNode,
172+
null,
173+
rendererFactory.createRenderer(host, newDef),
174+
null,
175+
null,
176+
null,
177+
);
160178

161-
// Create a new LView from the new TView, but reusing the existing TNode and DOM node.
162-
const newLView = createLView(
163-
parentLView,
164-
newTView,
165-
instance,
166-
getInitialLViewFlagsFromDef(newDef),
167-
host,
168-
tNode,
169-
null,
170-
rendererFactory.createRenderer(host, newDef),
171-
null,
172-
null,
173-
null,
174-
);
179+
// Detach the LView from its current place in the tree so we don't
180+
// start traversing any siblings and modifying their structure.
181+
replaceLViewInTree(parentLView, lView, newLView, tNode.index);
175182

176-
// Detach the LView from its current place in the tree so we don't
177-
// start traversing any siblings and modifying their structure.
178-
replaceLViewInTree(parentLView, lView, newLView, tNode.index);
183+
// Destroy the detached LView.
184+
destroyLView(lView[TVIEW], lView);
179185

180-
// Destroy the detached LView.
181-
destroyLView(lView[TVIEW], lView);
186+
// Remove the nodes associated with the destroyed LView. This removes the
187+
// descendants, but not the host which we want to stay in place.
188+
removeViewFromDOM(lView[TVIEW], lView);
182189

183-
// Remove the nodes associated with the destroyed LView. This removes the
184-
// descendants, but not the host which we want to stay in place.
185-
removeViewFromDOM(lView[TVIEW], lView);
190+
// Reset the content projection state of the TNode before the first render.
191+
// Note that this has to happen after the LView has been destroyed or we
192+
// risk some projected nodes not being removed correctly.
193+
resetProjectionState(tNode);
186194

187-
// Reset the content projection state of the TNode before the first render.
188-
// Note that this has to happen after the LView has been destroyed or we
189-
// risk some projected nodes not being removed correctly.
190-
resetProjectionState(tNode);
195+
// Creation pass for the new view.
196+
renderView(newTView, newLView, instance);
191197

192-
// Creation pass for the new view.
193-
renderView(newTView, newLView, instance);
198+
// Update pass for the new view.
199+
refreshView(newTView, newLView, newTView.template, instance);
200+
};
194201

195-
// Update pass for the new view.
196-
refreshView(newTView, newLView, newTView.template, instance);
202+
// The callback isn't guaranteed to be inside the Zone so we need to bring it in ourselves.
203+
if (zone === null) {
204+
recreate();
205+
} else {
206+
zone.run(recreate);
207+
}
197208
}
198209

199210
/**

packages/core/test/acceptance/hmr_spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
inject,
1616
InjectionToken,
1717
Input,
18+
NgZone,
1819
OnChanges,
1920
OnDestroy,
2021
OnInit,
@@ -32,6 +33,7 @@ import {compileComponent} from '@angular/core/src/render3/jit/directive';
3233
import {angularCoreEnv} from '@angular/core/src/render3/jit/environment';
3334
import {clearTranslations, loadTranslations} from '@angular/localize';
3435
import {computeMsgId} from '@angular/compiler';
36+
import {EVENT_MANAGER_PLUGINS} from '@angular/platform-browser';
3537

3638
describe('hot module replacement', () => {
3739
it('should recreate a single usage of a basic component', () => {
@@ -1213,6 +1215,47 @@ describe('hot module replacement', () => {
12131215
fixture.detectChanges();
12141216
expect(count).toBe(2);
12151217
});
1218+
1219+
it('should bind events inside the NgZone after a replacement', () => {
1220+
const calls: {name: string; inZone: boolean}[] = [];
1221+
1222+
@Component({template: `<button (click)="clicked()"></button>`})
1223+
class App {
1224+
clicked() {}
1225+
}
1226+
1227+
TestBed.configureTestingModule({
1228+
providers: [
1229+
{
1230+
// Note: TestBed brings things into the zone even if they aren't which makes this
1231+
// test hard to write. We have to intercept the listener being bound at the renderer
1232+
// level in order to get a true sense if it'll be bound inside or outside the zone.
1233+
// We do so with a custom event manager.
1234+
provide: EVENT_MANAGER_PLUGINS,
1235+
multi: true,
1236+
useValue: {
1237+
supports: () => true,
1238+
addEventListener: (_: unknown, name: string) => {
1239+
calls.push({name, inZone: NgZone.isInAngularZone()});
1240+
return () => {};
1241+
},
1242+
},
1243+
},
1244+
],
1245+
});
1246+
1247+
const fixture = TestBed.createComponent(App);
1248+
fixture.detectChanges();
1249+
expect(calls).toEqual([{name: 'click', inZone: true}]);
1250+
1251+
replaceMetadata(App, {template: '<button class="foo" (click)="clicked()"></button>'});
1252+
fixture.detectChanges();
1253+
1254+
expect(calls).toEqual([
1255+
{name: 'click', inZone: true},
1256+
{name: 'click', inZone: true},
1257+
]);
1258+
});
12161259
});
12171260

12181261
describe('directives', () => {

0 commit comments

Comments
 (0)