Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit d7575c2

Browse files
crisbetoAndrewKushnir
authored andcommitted
fix(core): replace metadata in place during HMR (#59644)
Currently during HMR we swap out the entire module definition (e.g. `MyComp.ɵcmp = newDef`). In standalone components and most module-based ones this works fine, however in some cases (e.g. circular dependencies) the compiler can produce a `setComponentScope` call for a module-based component. This call doesn't make it into the HMR replacement function, because it is defined in the module's file, not the component's. As a result, the dependencies of these components are cleared out upon replacement. A secondary problem is that the `directiveDefs` and `pipeDefs` fields can save references to definitions that later become stale as a result of HMR. These changes resolve both issues by: 1. Performing the replacement by copying the properties from the new definition onto the old one, while keeping it in place. 2. Preserving the initial `directiveDefs`, `pipeDefs` and `setInput`. Fixes #59639. PR Close #59644
1 parent 67fe0b9 commit d7575c2

File tree

2 files changed

+84
-8
lines changed

2 files changed

+84
-8
lines changed

packages/core/src/render3/hmr.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {Type} from '../interface/type';
10-
import {assertDefined, assertNotEqual} from '../util/assert';
10+
import {assertDefined, assertEqual, assertNotEqual} from '../util/assert';
1111
import {assertLView} from './assert';
1212
import {getComponentDef} from './def_getters';
1313
import {assertComponentDef} from './errors';
@@ -45,6 +45,7 @@ import {destroyLView, removeViewFromDOM} from './node_manipulation';
4545
import {RendererFactory} from './interfaces/renderer';
4646
import {NgZone} from '../zone';
4747
import {ViewEncapsulation} from '../metadata/view';
48+
import {NG_COMP_DEF} from './fields';
4849

4950
/**
5051
* Replaces the metadata of a component type and re-renders all live instances of the component.
@@ -61,7 +62,7 @@ export function ɵɵreplaceMetadata(
6162
locals: unknown[],
6263
) {
6364
ngDevMode && assertComponentDef(type);
64-
const oldDef = getComponentDef(type)!;
65+
const currentDef = getComponentDef(type)!;
6566

6667
// The reason `applyMetadata` is a callback that is invoked (almost) immediately is because
6768
// the compiler usually produces more code than just the component definition, e.g. there
@@ -70,6 +71,13 @@ export function ɵɵreplaceMetadata(
7071
// them at the right time.
7172
applyMetadata.apply(null, [type, namespaces, ...locals]);
7273

74+
const {newDef, oldDef} = mergeWithExistingDefinition(currentDef, getComponentDef(type)!);
75+
76+
// TODO(crisbeto): the `applyMetadata` call above will replace the definition on the type.
77+
// Ideally we should adjust the compiler output so the metadata is returned, however that'll
78+
// require some internal changes. We re-add the metadata here manually.
79+
(type as any)[NG_COMP_DEF] = newDef;
80+
7381
// If a `tView` hasn't been created yet, it means that this component hasn't been instantianted
7482
// before. In this case there's nothing left for us to do aside from patching it in.
7583
if (oldDef.tView) {
@@ -78,18 +86,54 @@ export function ɵɵreplaceMetadata(
7886
// Note: we have the additional check, because `IsRoot` can also indicate
7987
// a component created through something like `createComponent`.
8088
if (root[FLAGS] & LViewFlags.IsRoot && root[PARENT] === null) {
81-
recreateMatchingLViews(oldDef, root);
89+
recreateMatchingLViews(newDef, oldDef, root);
8290
}
8391
}
8492
}
8593
}
8694

95+
/**
96+
* Merges two component definitions while preseving the original one in place.
97+
* @param currentDef Definition that should receive the new metadata.
98+
* @param newDef Source of the new metadata.
99+
*/
100+
function mergeWithExistingDefinition(
101+
currentDef: ComponentDef<unknown>,
102+
newDef: ComponentDef<unknown>,
103+
) {
104+
// Clone the current definition since we reference its original data further
105+
// down in the replacement process (e.g. when destroying the renderer).
106+
const clone = {...currentDef};
107+
108+
// Assign the new metadata in place while preserving the object literal. It's important to
109+
// Keep the object in place, because there can be references to it, for example in the
110+
// `directiveDefs` of another definition.
111+
const replacement = Object.assign(currentDef, newDef, {
112+
// We need to keep the existing directive and pipe defs, because they can get patched on
113+
// by a call to `setComponentScope` from a module file. That call won't make it into the
114+
// HMR replacement function, because it lives in an entirely different file.
115+
directiveDefs: clone.directiveDefs,
116+
pipeDefs: clone.pipeDefs,
117+
118+
// Preserve the old `setInput` function, because it has some state.
119+
// This is fine, because the component instance is preserved as well.
120+
setInput: clone.setInput,
121+
});
122+
123+
ngDevMode && assertEqual(replacement, currentDef, 'Expected definition to be merged in place');
124+
return {newDef: replacement, oldDef: clone};
125+
}
126+
87127
/**
88128
* Finds all LViews matching a specific component definition and recreates them.
89129
* @param oldDef Component definition to search for.
90130
* @param rootLView View from which to start the search.
91131
*/
92-
function recreateMatchingLViews(oldDef: ComponentDef<unknown>, rootLView: LView): void {
132+
function recreateMatchingLViews(
133+
newDef: ComponentDef<unknown>,
134+
oldDef: ComponentDef<unknown>,
135+
rootLView: LView,
136+
): void {
93137
ngDevMode &&
94138
assertDefined(
95139
oldDef.tView,
@@ -102,7 +146,7 @@ function recreateMatchingLViews(oldDef: ComponentDef<unknown>, rootLView: LView)
102146
// produce false positives when using inheritance.
103147
if (tView === oldDef.tView) {
104148
ngDevMode && assertComponentDef(oldDef.type);
105-
recreateLView(getComponentDef(oldDef.type)!, oldDef, rootLView);
149+
recreateLView(newDef, oldDef, rootLView);
106150
return;
107151
}
108152

@@ -112,14 +156,14 @@ function recreateMatchingLViews(oldDef: ComponentDef<unknown>, rootLView: LView)
112156
if (isLContainer(current)) {
113157
// The host can be an LView if a component is injecting `ViewContainerRef`.
114158
if (isLView(current[HOST])) {
115-
recreateMatchingLViews(oldDef, current[HOST]);
159+
recreateMatchingLViews(newDef, oldDef, current[HOST]);
116160
}
117161

118162
for (let j = CONTAINER_HEADER_OFFSET; j < current.length; j++) {
119-
recreateMatchingLViews(oldDef, current[j]);
163+
recreateMatchingLViews(newDef, oldDef, current[j]);
120164
}
121165
} else if (isLView(current)) {
122-
recreateMatchingLViews(oldDef, current);
166+
recreateMatchingLViews(newDef, oldDef, current);
123167
}
124168
}
125169
}

packages/core/test/acceptance/hmr_spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ import {
2929
ViewEncapsulation,
3030
ɵNG_COMP_DEF,
3131
ɵɵreplaceMetadata,
32+
ɵɵsetComponentScope,
3233
} from '@angular/core';
3334
import {TestBed} from '@angular/core/testing';
3435
import {compileComponent} from '@angular/core/src/render3/jit/directive';
3536
import {angularCoreEnv} from '@angular/core/src/render3/jit/environment';
3637
import {clearTranslations, loadTranslations} from '@angular/localize';
3738
import {computeMsgId} from '@angular/compiler';
3839
import {EVENT_MANAGER_PLUGINS} from '@angular/platform-browser';
40+
import {ComponentType} from '@angular/core/src/render3';
3941

4042
describe('hot module replacement', () => {
4143
it('should recreate a single usage of a basic component', () => {
@@ -529,6 +531,36 @@ describe('hot module replacement', () => {
529531
);
530532
});
531533

534+
it('should carry over dependencies defined by setComponentScope', () => {
535+
// In some cases the AoT compiler produces a `setComponentScope` for non-standalone
536+
// components. We simulate it here by declaring two components that are not standalone
537+
// and manually calling `setComponentScope`.
538+
@Component({selector: 'child-cmp', template: 'hello', standalone: false})
539+
class ChildCmp {}
540+
541+
@Component({template: 'Initial <child-cmp/>', standalone: false})
542+
class RootCmp {}
543+
544+
ɵɵsetComponentScope(RootCmp as ComponentType<RootCmp>, [ChildCmp], []);
545+
546+
const fixture = TestBed.createComponent(RootCmp);
547+
fixture.detectChanges();
548+
markNodesAsCreatedInitially(fixture.nativeElement);
549+
expectHTML(fixture.nativeElement, 'Initial <child-cmp>hello</child-cmp>');
550+
551+
replaceMetadata(RootCmp, {
552+
standalone: false,
553+
template: 'Changed <child-cmp/>',
554+
});
555+
fixture.detectChanges();
556+
557+
const recreatedNodes = childrenOf(fixture.nativeElement);
558+
verifyNodesRemainUntouched(fixture.nativeElement, recreatedNodes);
559+
verifyNodesWereRecreated(recreatedNodes);
560+
561+
expectHTML(fixture.nativeElement, 'Changed <child-cmp>hello</child-cmp>');
562+
});
563+
532564
describe('queries', () => {
533565
it('should update ViewChildren query results', async () => {
534566
@Component({

0 commit comments

Comments
 (0)