Skip to content

Commit e2cb4cc

Browse files
committed
feat(compiler-cli): support partial evaluation and ReturnType in isolated declarations
attempt to statically evaluate @NgModule imports and exports in isolated declarations mode. if resolution succeeds, emit the resolved values as concrete references. if resolution fails, fall back to purely syntactic transforms, such as transforming calls to ReturnType<typeof ...>. also add validation to ensure expressions falling back to typeof are valid entity names, producing a LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION diagnostic otherwise. this allows emitting correct type references in .d.ts files without needing full resolution of external references, while supporting common patterns like forRoot() and local evaluation where possible. TAG=agy CONV=51a2b6d6-5679-49cf-8fa6-61fbc69628be
1 parent 8b3973a commit e2cb4cc

11 files changed

Lines changed: 987 additions & 58 deletions

File tree

packages/compiler-cli/src/ngtsc/annotations/ng_module/src/handler.ts

Lines changed: 227 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
ReturnStatement,
3232
SchemaMetadata,
3333
Statement,
34+
TypeofExpr,
3435
WrappedNodeExpr,
3536
} from '@angular/compiler';
3637
import ts from 'typescript';
@@ -45,6 +46,7 @@ import {
4546
assertSuccessfulReferenceEmit,
4647
LocalCompilationExtraImportsTracker,
4748
Reference,
49+
ReferenceEmitKind,
4850
ReferenceEmitter,
4951
} from '../../../imports';
5052
import {
@@ -104,6 +106,7 @@ import {
104106
ReferencesRegistry,
105107
resolveProvidersRequiringFactory,
106108
toR3Reference,
109+
tryUnwrapForwardRef,
107110
unwrapExpression,
108111
wrapFunctionExpressionsInParens,
109112
wrapTypeReference,
@@ -352,14 +355,21 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<
352355
return {};
353356
}
354357

358+
// In declaration-only emission the `declarations`/`imports`/`exports` arrays are emitted via a
359+
// purely syntactic transform - we don't attempt static resolution at all (that machinery is
360+
// only needed for the regular emit path) and produce the `Isolated` metadata kind directly
361+
// from the raw decorator expressions.
362+
if (this.emitDeclarationOnly) {
363+
return this.analyzeForDeclarationOnly(node, name, ngModule, decorator);
364+
}
365+
355366
const forwardRefResolver = createForwardRefResolver(this.isCore);
356367
const moduleResolvers = combineResolvers([
357368
createModuleWithProvidersResolver(this.reflector, this.isCore),
358369
forwardRefResolver,
359370
]);
360371

361-
const allowUnresolvedReferences =
362-
this.compilationMode === CompilationMode.LOCAL && !this.emitDeclarationOnly;
372+
const allowUnresolvedReferences = this.compilationMode === CompilationMode.LOCAL;
363373
const diagnostics: ts.Diagnostic[] = [];
364374

365375
// Resolving declarations
@@ -552,6 +562,7 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<
552562
const type = wrapTypeReference(node);
553563

554564
let ngModuleMetadata: R3NgModuleMetadata;
565+
555566
if (allowUnresolvedReferences) {
556567
ngModuleMetadata = {
557568
kind: R3NgModuleMetadataKind.Local,
@@ -1093,6 +1104,203 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<
10931104
}
10941105
}
10951106

1107+
/**
1108+
* Analyze path used in `emitDeclarationOnly` (isolated declarations) mode. The
1109+
* `declarations`/`imports`/`exports` arrays are NOT statically resolved here - they're transformed
1110+
* syntactically into the `Isolated` metadata kind's type-tuple expressions, which downstream
1111+
* `.d.ts` metadata readers resolve. This skips the partial-evaluator path entirely.
1112+
*/
1113+
private analyzeForDeclarationOnly(
1114+
node: ClassDeclaration,
1115+
name: string,
1116+
ngModule: Map<string, ts.Expression>,
1117+
decorator: Readonly<Decorator>,
1118+
): AnalysisOutput<NgModuleAnalysis> {
1119+
const diagnostics: ts.Diagnostic[] = [];
1120+
const rawDeclarations = ngModule.get('declarations') ?? null;
1121+
const rawImports = ngModule.get('imports') ?? null;
1122+
const rawExports = ngModule.get('exports') ?? null;
1123+
const rawBootstrap = ngModule.get('bootstrap') ?? null;
1124+
const rawProviders = ngModule.has('providers') ? ngModule.get('providers')! : null;
1125+
1126+
let id: Expression | null = null;
1127+
if (ngModule.has('id')) {
1128+
const idExpr = ngModule.get('id')!;
1129+
if (!isModuleIdExpression(idExpr)) {
1130+
id = new WrappedNodeExpr(idExpr);
1131+
} else {
1132+
const diag = makeDiagnostic(
1133+
ErrorCode.WARN_NGMODULE_ID_UNNECESSARY,
1134+
idExpr,
1135+
`Using 'module.id' for NgModule.id is a common anti-pattern that is ignored by the Angular compiler.`,
1136+
);
1137+
diag.category = ts.DiagnosticCategory.Warning;
1138+
diagnostics.push(diag);
1139+
}
1140+
}
1141+
1142+
const type = wrapTypeReference(node);
1143+
1144+
// Builds a single type-tuple element for the `.d.ts` from a raw `imports`/`exports`/
1145+
// `declarations` array element, without statically resolving it:
1146+
// - `Foo` -> `typeof Foo`
1147+
// - `Foo.forRoot()` / `fn()` -> `ReturnType<typeof Foo.forRoot>` / `ReturnType<typeof fn>`
1148+
// - `forwardRef(() => Foo)` -> unwrapped, then handled as `Foo`
1149+
const transformToTypeTupleElement = (el: ts.Expression): Expression => {
1150+
el = unwrapExpression(el);
1151+
1152+
const forwardRefUnwrapped = tryUnwrapForwardRef(el, this.reflector);
1153+
if (forwardRefUnwrapped !== null) {
1154+
return transformToTypeTupleElement(forwardRefUnwrapped);
1155+
}
1156+
1157+
// A call expression (e.g. `Foo.forRoot()` or a bare `fn()`) cannot be referenced with a
1158+
// `typeof` query directly. Instead emit `ReturnType<typeof callee>` so that the `.d.ts`
1159+
// reader can resolve the (potentially `ModuleWithProviders<T>`) return type later.
1160+
if (ts.isCallExpression(el)) {
1161+
const callee = expressionToEntityName(el.expression);
1162+
if (callee !== null) {
1163+
return new WrappedNodeExpr(
1164+
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('ReturnType'), [
1165+
ts.factory.createTypeQueryNode(callee),
1166+
]),
1167+
);
1168+
}
1169+
}
1170+
1171+
if (expressionToEntityName(el) === null) {
1172+
const diag = makeDiagnostic(
1173+
ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION,
1174+
el,
1175+
`In experimental declaration-only emission mode, this expression is not supported in NgModule imports/exports as it cannot be referenced with 'typeof'. Use a direct reference or a supported call.`,
1176+
);
1177+
diagnostics.push(diag);
1178+
return new WrappedNodeExpr(ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword));
1179+
}
1180+
1181+
return new TypeofExpr(new WrappedNodeExpr(el));
1182+
};
1183+
1184+
const resolvedToTypeTupleElement = (
1185+
originalEl: ts.Expression,
1186+
resolved: ResolvedValue,
1187+
): Expression => {
1188+
if (resolved instanceof Reference) {
1189+
const emitted = this.refEmitter.emit(resolved, node.getSourceFile());
1190+
if (emitted.kind === ReferenceEmitKind.Success) {
1191+
return new TypeofExpr(emitted.expression);
1192+
}
1193+
}
1194+
1195+
if (Array.isArray(resolved)) {
1196+
const elements: Expression[] = [];
1197+
let allValid = true;
1198+
for (const item of resolved) {
1199+
if (item instanceof Reference) {
1200+
const emitted = this.refEmitter.emit(item, node.getSourceFile());
1201+
if (emitted.kind === ReferenceEmitKind.Success) {
1202+
elements.push(new TypeofExpr(emitted.expression));
1203+
continue;
1204+
}
1205+
}
1206+
allValid = false;
1207+
break;
1208+
}
1209+
if (allValid && elements.length > 0) {
1210+
return new LiteralArrayExpr(elements);
1211+
}
1212+
}
1213+
1214+
// Fallback to syntactic transform
1215+
return transformToTypeTupleElement(originalEl);
1216+
};
1217+
1218+
const transformToTypeTupleExpression = (expr: ts.Expression): Expression | null => {
1219+
if (ts.isArrayLiteralExpression(expr)) {
1220+
// An empty array is treated like an omitted slot (emitted as `never` by
1221+
// `createNgModuleType`), matching the standard compilation path.
1222+
if (expr.elements.length === 0) {
1223+
return null;
1224+
}
1225+
return new LiteralArrayExpr(
1226+
expr.elements.map((el) => {
1227+
const resolved = this.evaluator.evaluate(el);
1228+
return resolvedToTypeTupleElement(el, resolved);
1229+
}),
1230+
);
1231+
}
1232+
const resolved = this.evaluator.evaluate(expr);
1233+
return resolvedToTypeTupleElement(expr, resolved);
1234+
};
1235+
1236+
const ngModuleMetadata: R3NgModuleMetadata = {
1237+
kind: R3NgModuleMetadataKind.Isolated,
1238+
type,
1239+
bootstrapExpression: rawBootstrap ? new WrappedNodeExpr(rawBootstrap) : null,
1240+
declarationsExpression: rawDeclarations
1241+
? transformToTypeTupleExpression(rawDeclarations)
1242+
: null,
1243+
importsExpression: rawImports ? transformToTypeTupleExpression(rawImports) : null,
1244+
exportsExpression: rawExports ? transformToTypeTupleExpression(rawExports) : null,
1245+
id,
1246+
selectorScopeMode: R3SelectorScopeMode.Omit,
1247+
schemas: [],
1248+
};
1249+
1250+
// Providers are emitted as-is - they are needed for the injector but don't go through any
1251+
// resolution at this stage.
1252+
let wrappedProviders: WrappedNodeExpr<ts.Expression> | null = null;
1253+
if (
1254+
rawProviders !== null &&
1255+
(!ts.isArrayLiteralExpression(rawProviders) || rawProviders.elements.length > 0)
1256+
) {
1257+
wrappedProviders = new WrappedNodeExpr(
1258+
this.annotateForClosureCompiler
1259+
? wrapFunctionExpressionsInParens(rawProviders)
1260+
: rawProviders,
1261+
);
1262+
}
1263+
1264+
const injectorMetadata: R3InjectorMetadata = {
1265+
name,
1266+
type,
1267+
providers: wrappedProviders,
1268+
imports: [],
1269+
};
1270+
1271+
const factoryMetadata: R3FactoryMetadata = {
1272+
name,
1273+
type,
1274+
typeArgumentCount: 0,
1275+
deps: getValidConstructorDependencies(node, this.reflector, this.isCore),
1276+
target: FactoryTarget.NgModule,
1277+
};
1278+
1279+
return {
1280+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
1281+
analysis: {
1282+
id,
1283+
schemas: [],
1284+
mod: ngModuleMetadata,
1285+
inj: injectorMetadata,
1286+
fac: factoryMetadata,
1287+
declarations: [],
1288+
rawDeclarations,
1289+
imports: [],
1290+
rawImports,
1291+
importRefs: [],
1292+
exports: [],
1293+
rawExports,
1294+
providers: rawProviders,
1295+
providersRequiringFactory: null,
1296+
classMetadata: null,
1297+
factorySymbolName: node.name.text,
1298+
remoteScopesMayRequireCycleProtection: false,
1299+
decorator: (decorator?.node as ts.Decorator | null) ?? null,
1300+
},
1301+
};
1302+
}
1303+
10961304
// Verify that a "Declaration" reference is a `ClassDeclaration` reference.
10971305
private isClassDeclarationReference(ref: Reference): ref is Reference<ClassDeclaration> {
10981306
return this.reflector.isClass(ref.node);
@@ -1176,17 +1384,6 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<
11761384
} else if (entry instanceof DynamicValue && allowUnresolvedReferences) {
11771385
dynamicValueSet.add(entry);
11781386
continue;
1179-
} else if (
1180-
this.emitDeclarationOnly &&
1181-
entry instanceof DynamicValue &&
1182-
entry.isFromUnknownIdentifier()
1183-
) {
1184-
throw createValueHasWrongTypeError(
1185-
entry.node,
1186-
entry,
1187-
`Value at position ${absoluteIndex} in the NgModule.${arrayName} of ${className} is an external reference. ` +
1188-
'External references in @NgModule declarations are not supported in experimental declaration-only emission mode',
1189-
);
11901387
} else {
11911388
// TODO(alxhub): Produce a better diagnostic here - the array index may be an inner array.
11921389
throw createValueHasWrongTypeError(
@@ -1257,3 +1454,20 @@ function makeStandaloneBootstrapDiagnostic(
12571454
function isSyntheticReference(ref: Reference<DeclarationNode>): boolean {
12581455
return ref.synthetic;
12591456
}
1457+
1458+
/**
1459+
* Converts a value expression that is an identifier or a chain of property accesses on identifiers
1460+
* (e.g. `Foo` or `Foo.bar`) into the equivalent `ts.EntityName`, reusing the original identifier
1461+
* nodes so that any imports they reference are preserved by TypeScript's declaration emitter.
1462+
* Returns `null` for any other shape of expression.
1463+
*/
1464+
function expressionToEntityName(expr: ts.Expression): ts.EntityName | null {
1465+
if (ts.isIdentifier(expr)) {
1466+
return expr;
1467+
}
1468+
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) {
1469+
const left = expressionToEntityName(expr.expression);
1470+
return left === null ? null : ts.factory.createQualifiedName(left, expr.name.text);
1471+
}
1472+
return null;
1473+
}

packages/compiler-cli/src/ngtsc/metadata/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ts_project(
1212
"//packages/compiler",
1313
"//packages/compiler-cli/src/ngtsc/file_system",
1414
"//packages/compiler-cli/src/ngtsc/imports",
15+
"//packages/compiler-cli/src/ngtsc/partial_evaluator",
1516
"//packages/compiler-cli/src/ngtsc/reflection",
1617
"//packages/compiler-cli/src/ngtsc/util",
1718
],

packages/compiler-cli/src/ngtsc/metadata/src/dts.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {ClassPropertyMapping, MatchSource} from '@angular/compiler';
1010
import ts from 'typescript';
1111

1212
import {OwningModule, Reference} from '../../imports';
13+
import {PartialEvaluator, ResolvedValue} from '../../partial_evaluator/index';
1314
import {
1415
ClassDeclaration,
1516
isNamedClassDeclaration,
@@ -29,7 +30,6 @@ import {
2930
} from './api';
3031
import {
3132
extractDirectiveTypeCheckMeta,
32-
extractReferencesFromType,
3333
extraReferenceFromTypeQuery,
3434
readBooleanType,
3535
readMapType,
@@ -42,10 +42,14 @@ import {
4242
* from an upstream compilation already.
4343
*/
4444
export class DtsMetadataReader implements MetadataReader {
45+
private evaluator: PartialEvaluator;
46+
4547
constructor(
4648
private checker: ts.TypeChecker,
4749
private reflector: ReflectionHost,
48-
) {}
50+
) {
51+
this.evaluator = new PartialEvaluator(this.reflector, this.checker, null);
52+
}
4953

5054
/**
5155
* Read the metadata from a class that has already been compiled somehow (either it's in a .d.ts
@@ -76,19 +80,16 @@ export class DtsMetadataReader implements MetadataReader {
7680
// Read the ModuleData out of the type arguments.
7781
const [_, declarationMetadata, importMetadata, exportMetadata] = ngModuleDef.type.typeArguments;
7882

79-
const declarations = extractReferencesFromType(
80-
this.checker,
81-
declarationMetadata,
83+
const declarations = this.extractReferencesFromResolvedValue(
84+
this.evaluator.evaluateType(declarationMetadata, ref.bestGuessOwningModule),
8285
ref.bestGuessOwningModule,
8386
);
84-
const exports = extractReferencesFromType(
85-
this.checker,
86-
exportMetadata,
87+
const exports = this.extractReferencesFromResolvedValue(
88+
this.evaluator.evaluateType(exportMetadata, ref.bestGuessOwningModule),
8789
ref.bestGuessOwningModule,
8890
);
89-
const imports = extractReferencesFromType(
90-
this.checker,
91-
importMetadata,
91+
const imports = this.extractReferencesFromResolvedValue(
92+
this.evaluator.evaluateType(importMetadata, ref.bestGuessOwningModule),
9293
ref.bestGuessOwningModule,
9394
);
9495

@@ -116,6 +117,30 @@ export class DtsMetadataReader implements MetadataReader {
116117
};
117118
}
118119

120+
private extractReferencesFromResolvedValue(
121+
value: ResolvedValue,
122+
bestGuessOwningModule: OwningModule | null,
123+
): {result: Reference<ClassDeclaration>[]; isIncomplete: boolean} {
124+
const result: Reference<ClassDeclaration>[] = [];
125+
let isIncomplete = false;
126+
127+
if (Array.isArray(value)) {
128+
for (const element of value) {
129+
if (element instanceof Reference && this.reflector.isClass(element.node)) {
130+
result.push(element as Reference<ClassDeclaration>);
131+
} else {
132+
isIncomplete = true;
133+
}
134+
}
135+
} else if (value instanceof Reference && this.reflector.isClass(value.node)) {
136+
result.push(value as Reference<ClassDeclaration>);
137+
} else {
138+
isIncomplete = true;
139+
}
140+
141+
return {result, isIncomplete};
142+
}
143+
119144
/**
120145
* Read directive (or component) metadata from a referenced class in a .d.ts file.
121146
*/

0 commit comments

Comments
 (0)