Skip to content

Commit 3acebdc

Browse files
JoostKAndrewKushnir
authored andcommitted
fix(core): prevent NgModule scope being overwritten in JIT compiler (#37795)
In JIT compiled apps, component definitions are compiled upon first access. For a component class `A` that extends component class `B`, the `B` component is also compiled when the `InheritDefinitionFeature` runs during the compilation of `A` before it has finalized. A problem arises when the compilation of `B` would flush the NgModule scoping queue, where the NgModule declaring `A` is still pending. The scope information would be applied to the definition of `A`, but its compilation is still in progress so requesting the component definition would compile `A` again from scratch. This "inner compilation" is correctly assigned the NgModule scope, but once the "outer compilation" of `A` finishes it would overwrite the inner compilation's definition, losing the NgModule scope information. In summary, flushing the NgModule scope queue could trigger a reentrant compilation, where JIT compilation is non-reentrant. To avoid the reentrant compilation, a compilation depth counter is introduced to avoid flushing the NgModule scope during nested compilations. Fixes #37105 PR Close #37795
1 parent 02aca13 commit 3acebdc

File tree

2 files changed

+90
-11
lines changed

2 files changed

+90
-11
lines changed

packages/core/src/render3/jit/directive.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,20 @@ import {angularCoreEnv} from './environment';
2626
import {getJitOptions} from './jit_options';
2727
import {flushModuleScopingQueueAsMuchAsPossible, patchComponentDefWithScope, transitiveScopesFor} from './module';
2828

29-
29+
/**
30+
* Keep track of the compilation depth to avoid reentrancy issues during JIT compilation. This
31+
* matters in the following scenario:
32+
*
33+
* Consider a component 'A' that extends component 'B', both declared in module 'M'. During
34+
* the compilation of 'A' the definition of 'B' is requested to capture the inheritance chain,
35+
* potentially triggering compilation of 'B'. If this nested compilation were to trigger
36+
* `flushModuleScopingQueueAsMuchAsPossible` it may happen that module 'M' is still pending in the
37+
* queue, resulting in 'A' and 'B' to be patched with the NgModule scope. As the compilation of
38+
* 'A' is still in progress, this would introduce a circular dependency on its compilation. To avoid
39+
* this issue, the module scope queue is only flushed for compilations at the depth 0, to ensure
40+
* all compilations have finished.
41+
*/
42+
let compilationDepth = 0;
3043

3144
/**
3245
* Compile an Angular component according to its decorator metadata, and patch the resulting
@@ -106,18 +119,26 @@ export function compileComponent(type: Type<any>, metadata: Component): void {
106119
interpolation: metadata.interpolation,
107120
viewProviders: metadata.viewProviders || null,
108121
};
109-
if (meta.usesInheritance) {
110-
addDirectiveDefToUndecoratedParents(type);
111-
}
112122

113-
ngComponentDef = compiler.compileComponent(angularCoreEnv, templateUrl, meta);
123+
compilationDepth++;
124+
try {
125+
if (meta.usesInheritance) {
126+
addDirectiveDefToUndecoratedParents(type);
127+
}
128+
ngComponentDef = compiler.compileComponent(angularCoreEnv, templateUrl, meta);
129+
} finally {
130+
// Ensure that the compilation depth is decremented even when the compilation failed.
131+
compilationDepth--;
132+
}
114133

115-
// When NgModule decorator executed, we enqueued the module definition such that
116-
// it would only dequeue and add itself as module scope to all of its declarations,
117-
// but only if if all of its declarations had resolved. This call runs the check
118-
// to see if any modules that are in the queue can be dequeued and add scope to
119-
// their declarations.
120-
flushModuleScopingQueueAsMuchAsPossible();
134+
if (compilationDepth === 0) {
135+
// When NgModule decorator executed, we enqueued the module definition such that
136+
// it would only dequeue and add itself as module scope to all of its declarations,
137+
// but only if if all of its declarations had resolved. This call runs the check
138+
// to see if any modules that are in the queue can be dequeued and add scope to
139+
// their declarations.
140+
flushModuleScopingQueueAsMuchAsPossible();
141+
}
121142

122143
// If component compilation is async, then the @NgModule annotation which declares the
123144
// component may execute and set an ngSelectorScope property on the component type. This
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Component, destroyPlatform, NgModule, Pipe, PipeTransform} from '@angular/core';
10+
import {BrowserModule} from '@angular/platform-browser';
11+
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
12+
import {withBody} from '@angular/private/testing';
13+
14+
describe('NgModule scopes', () => {
15+
beforeEach(destroyPlatform);
16+
afterEach(destroyPlatform);
17+
18+
it('should apply NgModule scope to a component that extends another component class',
19+
withBody('<my-app></my-app>', async () => {
20+
// Regression test for https://github.com/angular/angular/issues/37105
21+
//
22+
// This test reproduces a scenario that used to fail due to a reentrancy issue in Ivy's JIT
23+
// compiler. Extending a component from a decorated baseclass would inadvertently compile
24+
// the subclass twice. NgModule scope information would only be present on the initial
25+
// compilation, but then overwritten during the second compilation. This meant that the
26+
// baseclass did not have a NgModule scope, such that declarations are not available.
27+
//
28+
// The scenario cannot be tested using TestBed as it influences how NgModule
29+
// scopes are applied, preventing the issue from occurring.
30+
31+
@Pipe({name: 'multiply'})
32+
class MultiplyPipe implements PipeTransform {
33+
transform(value: number, factor: number): number {
34+
return value * factor;
35+
}
36+
}
37+
38+
@Component({template: '...'})
39+
class BaseComponent {
40+
}
41+
42+
@Component({selector: 'my-app', template: 'App - {{ 3 | multiply:2 }}'})
43+
class App extends BaseComponent {
44+
}
45+
46+
@NgModule({
47+
imports: [BrowserModule],
48+
declarations: [App, BaseComponent, MultiplyPipe],
49+
bootstrap: [App],
50+
})
51+
class Mod {
52+
}
53+
54+
const ngModuleRef = await platformBrowserDynamic().bootstrapModule(Mod);
55+
expect(document.body.textContent).toContain('App - 6');
56+
ngModuleRef.destroy();
57+
}));
58+
});

0 commit comments

Comments
 (0)