Skip to content

Commit b0ab653

Browse files
crisbetodevversion
authored andcommitted
fix(compiler-cli): report when NgModule imports or exports itself (#58231)
Reports a diagnostic if an NgModule refers to itself in its `imports` or `exports`. Previously the compiler would go into an infinite loop. Fixes #58224. PR Close #58231
1 parent 98c9ac8 commit b0ab653

File tree

2 files changed

+177
-9
lines changed

2 files changed

+177
-9
lines changed

packages/compiler-cli/src/ngtsc/scope/src/local.ts

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export interface LocalNgModuleData {
4545
exports: Reference<ClassDeclaration>[];
4646
}
4747

48+
/** Value used to mark a module whose scope is in the process of being resolved. */
49+
const IN_PROGRESS_RESOLUTION = {};
50+
4851
/**
4952
* A registry which collects information about NgModules, Directives, Components, and Pipes which
5053
* are local (declared in the ts.Program being compiled), and can produce `LocalModuleScope`s
@@ -96,7 +99,10 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
9699
* A cache of calculated `LocalModuleScope`s for each NgModule declared in the current program.
97100
98101
*/
99-
private cache = new Map<ClassDeclaration, LocalModuleScope | null>();
102+
private cache = new Map<
103+
ClassDeclaration,
104+
LocalModuleScope | typeof IN_PROGRESS_RESOLUTION | null
105+
>();
100106

101107
/**
102108
* Tracks the `RemoteScope` for components requiring "remote scoping".
@@ -245,9 +251,15 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
245251
*/
246252
private getScopeOfModuleReference(ref: Reference<ClassDeclaration>): LocalModuleScope | null {
247253
if (this.cache.has(ref.node)) {
248-
return this.cache.get(ref.node)!;
254+
const cachedValue = this.cache.get(ref.node);
255+
256+
if (cachedValue !== IN_PROGRESS_RESOLUTION) {
257+
return cachedValue as LocalModuleScope | null;
258+
}
249259
}
250260

261+
this.cache.set(ref.node, IN_PROGRESS_RESOLUTION);
262+
251263
// Seal the registry to protect the integrity of the `LocalModuleScope` cache.
252264
this.sealed = true;
253265

@@ -301,14 +313,22 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
301313
for (const decl of ngModule.imports) {
302314
const importScope = this.getExportedScope(decl, diagnostics, ref.node, 'import');
303315
if (importScope !== null) {
304-
if (importScope === 'invalid' || importScope.exported.isPoisoned) {
316+
if (
317+
importScope === 'invalid' ||
318+
importScope === 'cycle' ||
319+
importScope.exported.isPoisoned
320+
) {
305321
// An import was an NgModule but contained errors of its own. Record this as an error too,
306322
// because this scope is always going to be incorrect if one of its imports could not be
307323
// read.
308-
diagnostics.push(invalidTransitiveNgModuleRef(decl, ngModule.rawImports, 'import'));
309324
isPoisoned = true;
310325

311-
if (importScope === 'invalid') {
326+
// Prevent the module from reporting a diagnostic about itself when there's a cycle.
327+
if (importScope !== 'cycle') {
328+
diagnostics.push(invalidTransitiveNgModuleRef(decl, ngModule.rawImports, 'import'));
329+
}
330+
331+
if (importScope === 'invalid' || importScope === 'cycle') {
312332
continue;
313333
}
314334
}
@@ -427,14 +447,22 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
427447
for (const decl of ngModule.exports) {
428448
// Attempt to resolve decl as an NgModule.
429449
const exportScope = this.getExportedScope(decl, diagnostics, ref.node, 'export');
430-
if (exportScope === 'invalid' || (exportScope !== null && exportScope.exported.isPoisoned)) {
450+
if (
451+
exportScope === 'invalid' ||
452+
exportScope === 'cycle' ||
453+
(exportScope !== null && exportScope.exported.isPoisoned)
454+
) {
431455
// An export was an NgModule but contained errors of its own. Record this as an error too,
432456
// because this scope is always going to be incorrect if one of its exports could not be
433457
// read.
434-
diagnostics.push(invalidTransitiveNgModuleRef(decl, ngModule.rawExports, 'export'));
435458
isPoisoned = true;
436459

437-
if (exportScope === 'invalid') {
460+
// Prevent the module from reporting a diagnostic about itself when there's a cycle.
461+
if (exportScope !== 'cycle') {
462+
diagnostics.push(invalidTransitiveNgModuleRef(decl, ngModule.rawExports, 'export'));
463+
}
464+
465+
if (exportScope === 'invalid' || exportScope === 'cycle') {
438466
continue;
439467
}
440468
} else if (exportScope !== null) {
@@ -544,7 +572,7 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
544572
diagnostics: ts.Diagnostic[],
545573
ownerForErrors: DeclarationNode,
546574
type: 'import' | 'export',
547-
): ExportScope | null | 'invalid' {
575+
): ExportScope | null | 'invalid' | 'cycle' {
548576
if (ref.node.getSourceFile().isDeclarationFile) {
549577
// The NgModule is declared in a .d.ts file. Resolve it with the `DependencyScopeReader`.
550578
if (!ts.isClassDeclaration(ref.node)) {
@@ -565,6 +593,19 @@ export class LocalModuleScopeRegistry implements MetadataRegistry, ComponentScop
565593
}
566594
return this.dependencyScopeReader.resolve(ref);
567595
} else {
596+
if (this.cache.get(ref.node) === IN_PROGRESS_RESOLUTION) {
597+
diagnostics.push(
598+
makeDiagnostic(
599+
type === 'import'
600+
? ErrorCode.NGMODULE_INVALID_IMPORT
601+
: ErrorCode.NGMODULE_INVALID_EXPORT,
602+
identifierOfNode(ref.node) || ref.node,
603+
`NgModule "${type}" field contains a cycle`,
604+
),
605+
);
606+
return 'cycle';
607+
}
608+
568609
// The NgModule is declared locally in the current program. Resolve it from the registry.
569610
return this.getScopeOfModuleReference(ref);
570611
}

packages/compiler-cli/test/ngtsc/ngtsc_spec.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6936,6 +6936,133 @@ runInEachFileSystem((os: string) => {
69366936
'Is it missing an @NgModule annotation?',
69376937
);
69386938
});
6939+
6940+
it('should report if an NgModule imports itself', () => {
6941+
env.write(
6942+
'test.ts',
6943+
`
6944+
import {NgModule} from '@angular/core';
6945+
6946+
@NgModule({imports: [MyModule]})
6947+
export class MyModule {}
6948+
`,
6949+
);
6950+
6951+
const diags = env.driveDiagnostics();
6952+
expect(diags.length).toBe(1);
6953+
expect(diags[0].messageText).toBe('NgModule "import" field contains a cycle');
6954+
});
6955+
6956+
it('should report if an NgModule exports itself', () => {
6957+
env.write(
6958+
'test.ts',
6959+
`
6960+
import {NgModule} from '@angular/core';
6961+
6962+
@NgModule({exports: [MyModule]})
6963+
export class MyModule {}
6964+
`,
6965+
);
6966+
6967+
const diags = env.driveDiagnostics();
6968+
expect(diags.length).toBe(1);
6969+
expect(diags[0].messageText).toBe('NgModule "export" field contains a cycle');
6970+
});
6971+
6972+
it('should report if an NgModule imports itself transitively', () => {
6973+
env.write(
6974+
'dep-2.ts',
6975+
`
6976+
import {NgModule} from '@angular/core';
6977+
import {MyModule} from './test';
6978+
6979+
@NgModule({imports: [MyModule]})
6980+
export class DepModule2 {}
6981+
`,
6982+
);
6983+
6984+
env.write(
6985+
'dep.ts',
6986+
`
6987+
import {NgModule} from '@angular/core';
6988+
import {DepModule2} from './dep-2';
6989+
6990+
@NgModule({imports: [DepModule2]})
6991+
export class DepModule {}
6992+
`,
6993+
);
6994+
6995+
env.write(
6996+
'test.ts',
6997+
`
6998+
import {NgModule} from '@angular/core';
6999+
import {DepModule} from './dep';
7000+
7001+
@NgModule({imports: [DepModule]})
7002+
export class MyModule {}
7003+
`,
7004+
);
7005+
7006+
const diags = env.driveDiagnostics();
7007+
expect(diags.length).toBe(3);
7008+
expect(diags[0].messageText).toBe(
7009+
'This import contains errors, which may affect components that depend on this NgModule.',
7010+
);
7011+
expect(diags[1].messageText).toBe('NgModule "import" field contains a cycle');
7012+
expect(diags[2].messageText).toBe(
7013+
'This import contains errors, which may affect components that depend on this NgModule.',
7014+
);
7015+
});
7016+
7017+
it('should report if an NgModule imports itself via a forwardRef', () => {
7018+
env.write(
7019+
'test.ts',
7020+
`
7021+
import {NgModule, forwardRef} from '@angular/core';
7022+
7023+
@NgModule({imports: [forwardRef(() => MyModule)]})
7024+
export class DepModule {}
7025+
7026+
@NgModule({imports: [DepModule]})
7027+
export class MyModule {}
7028+
`,
7029+
);
7030+
7031+
const diags = env.driveDiagnostics();
7032+
expect(diags.length).toBe(2);
7033+
expect(diags[0].messageText).toBe(
7034+
'This import contains errors, which may affect components that depend on this NgModule.',
7035+
);
7036+
expect(diags[1].messageText).toBe('NgModule "import" field contains a cycle');
7037+
});
7038+
7039+
it('should report if an NgModule imports itself via a forwardRef', () => {
7040+
env.write(
7041+
'test.ts',
7042+
`
7043+
import {NgModule, forwardRef} from '@angular/core';
7044+
7045+
@NgModule({imports: [forwardRef(() => ModB)]})
7046+
class ModA {}
7047+
7048+
@NgModule({imports: [forwardRef(() => ModC)]})
7049+
class ModB {}
7050+
7051+
@NgModule({imports: [ModB]})
7052+
class ModC {}
7053+
`,
7054+
);
7055+
7056+
const diags = env.driveDiagnostics();
7057+
expect(diags.length).toBe(3);
7058+
expect(diags[0].messageText).toBe(
7059+
'This import contains errors, which may affect components that depend on this NgModule.',
7060+
);
7061+
expect(diags[1].messageText).toBe(
7062+
'This import contains errors, which may affect components that depend on this NgModule.',
7063+
);
7064+
expect(diags[2].messageText).toBe('NgModule "import" field contains a cycle');
7065+
});
69397066
});
69407067

69417068
describe('when processing external directives', () => {

0 commit comments

Comments
 (0)