Skip to content

Commit 179ab9b

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 179ab9b

12 files changed

Lines changed: 1033 additions & 63 deletions

File tree

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

Lines changed: 250 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,117 @@ 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+
}
1132+
}
1133+
1134+
const type = wrapTypeReference(node);
1135+
1136+
const ngModuleMetadata: R3NgModuleMetadata = {
1137+
kind: R3NgModuleMetadataKind.Isolated,
1138+
type,
1139+
importsExpression: rawImports
1140+
? transformToTypeTupleExpression(
1141+
rawImports,
1142+
this.evaluator,
1143+
this.refEmitter,
1144+
node.getSourceFile(),
1145+
this.reflector,
1146+
diagnostics,
1147+
)
1148+
: null,
1149+
exportsExpression: rawExports
1150+
? transformToTypeTupleExpression(
1151+
rawExports,
1152+
this.evaluator,
1153+
this.refEmitter,
1154+
node.getSourceFile(),
1155+
this.reflector,
1156+
diagnostics,
1157+
)
1158+
: null,
1159+
id,
1160+
selectorScopeMode: R3SelectorScopeMode.Omit,
1161+
schemas: [],
1162+
};
1163+
1164+
// Providers are emitted as-is - they are needed for the injector but don't go through any
1165+
// resolution at this stage.
1166+
let wrappedProviders: WrappedNodeExpr<ts.Expression> | null = null;
1167+
if (
1168+
rawProviders !== null &&
1169+
(!ts.isArrayLiteralExpression(rawProviders) || rawProviders.elements.length > 0)
1170+
) {
1171+
wrappedProviders = new WrappedNodeExpr(
1172+
this.annotateForClosureCompiler
1173+
? wrapFunctionExpressionsInParens(rawProviders)
1174+
: rawProviders,
1175+
);
1176+
}
1177+
1178+
const injectorMetadata: R3InjectorMetadata = {
1179+
name,
1180+
type,
1181+
providers: wrappedProviders,
1182+
imports: [],
1183+
};
1184+
1185+
const factoryMetadata: R3FactoryMetadata = {
1186+
name,
1187+
type,
1188+
typeArgumentCount: 0,
1189+
deps: getValidConstructorDependencies(node, this.reflector, this.isCore),
1190+
target: FactoryTarget.NgModule,
1191+
};
1192+
1193+
return {
1194+
diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
1195+
analysis: {
1196+
id,
1197+
schemas: [],
1198+
mod: ngModuleMetadata,
1199+
inj: injectorMetadata,
1200+
fac: factoryMetadata,
1201+
declarations: [],
1202+
rawDeclarations,
1203+
imports: [],
1204+
rawImports,
1205+
importRefs: [],
1206+
exports: [],
1207+
rawExports,
1208+
providers: rawProviders,
1209+
providersRequiringFactory: null,
1210+
classMetadata: null,
1211+
factorySymbolName: node.name.text,
1212+
remoteScopesMayRequireCycleProtection: false,
1213+
decorator: (decorator?.node as ts.Decorator | null) ?? null,
1214+
},
1215+
};
1216+
}
1217+
10961218
// Verify that a "Declaration" reference is a `ClassDeclaration` reference.
10971219
private isClassDeclarationReference(ref: Reference): ref is Reference<ClassDeclaration> {
10981220
return this.reflector.isClass(ref.node);
@@ -1176,17 +1298,6 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<
11761298
} else if (entry instanceof DynamicValue && allowUnresolvedReferences) {
11771299
dynamicValueSet.add(entry);
11781300
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-
);
11901301
} else {
11911302
// TODO(alxhub): Produce a better diagnostic here - the array index may be an inner array.
11921303
throw createValueHasWrongTypeError(
@@ -1257,3 +1368,129 @@ function makeStandaloneBootstrapDiagnostic(
12571368
function isSyntheticReference(ref: Reference<DeclarationNode>): boolean {
12581369
return ref.synthetic;
12591370
}
1371+
1372+
/**
1373+
* Converts a value expression that is an identifier or a chain of property accesses on identifiers
1374+
* (e.g. `Foo` or `Foo.bar`) into the equivalent `ts.EntityName`, reusing the original identifier
1375+
* nodes so that any imports they reference are preserved by TypeScript's declaration emitter.
1376+
* Returns `null` for any other shape of expression.
1377+
*/
1378+
function expressionToEntityName(expr: ts.Expression): ts.EntityName | null {
1379+
if (ts.isIdentifier(expr)) {
1380+
return expr;
1381+
}
1382+
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) {
1383+
const left = expressionToEntityName(expr.expression);
1384+
return left === null ? null : ts.factory.createQualifiedName(left, expr.name.text);
1385+
}
1386+
return null;
1387+
}
1388+
1389+
function transformToTypeTupleElement(
1390+
el: ts.Expression,
1391+
reflector: ReflectionHost,
1392+
diagnostics: ts.Diagnostic[],
1393+
): Expression {
1394+
el = unwrapExpression(el);
1395+
1396+
const forwardRefUnwrapped = tryUnwrapForwardRef(el, reflector);
1397+
if (forwardRefUnwrapped !== null) {
1398+
return transformToTypeTupleElement(forwardRefUnwrapped, reflector, diagnostics);
1399+
}
1400+
1401+
// A call expression (e.g. `Foo.forRoot()` or a bare `fn()`) cannot be referenced with a
1402+
// `typeof` query directly. Instead emit `ReturnType<typeof callee>` so that the `.d.ts`
1403+
// reader can resolve the (potentially `ModuleWithProviders<T>`) return type later.
1404+
if (ts.isCallExpression(el)) {
1405+
const callee = expressionToEntityName(el.expression);
1406+
if (callee !== null) {
1407+
return new WrappedNodeExpr(
1408+
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('ReturnType'), [
1409+
ts.factory.createTypeQueryNode(callee),
1410+
]),
1411+
);
1412+
}
1413+
}
1414+
1415+
if (expressionToEntityName(el) === null) {
1416+
const diag = makeDiagnostic(
1417+
ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION,
1418+
el,
1419+
`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.`,
1420+
);
1421+
diagnostics.push(diag);
1422+
return new WrappedNodeExpr(ts.factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword));
1423+
}
1424+
1425+
return new TypeofExpr(new WrappedNodeExpr(el));
1426+
}
1427+
1428+
function resolvedToTypeTupleElement(
1429+
originalEl: ts.Expression,
1430+
resolved: ResolvedValue,
1431+
refEmitter: ReferenceEmitter,
1432+
sourceFile: ts.SourceFile,
1433+
reflector: ReflectionHost,
1434+
diagnostics: ts.Diagnostic[],
1435+
): Expression {
1436+
if (resolved instanceof Reference) {
1437+
const emitted = refEmitter.emit(resolved, sourceFile);
1438+
if (emitted.kind === ReferenceEmitKind.Success) {
1439+
return new TypeofExpr(emitted.expression);
1440+
}
1441+
}
1442+
1443+
if (Array.isArray(resolved)) {
1444+
const elements: Expression[] = [];
1445+
let allValid = true;
1446+
for (const item of resolved) {
1447+
if (item instanceof Reference) {
1448+
const emitted = refEmitter.emit(item, sourceFile);
1449+
if (emitted.kind === ReferenceEmitKind.Success) {
1450+
elements.push(new TypeofExpr(emitted.expression));
1451+
continue;
1452+
}
1453+
}
1454+
allValid = false;
1455+
break;
1456+
}
1457+
if (allValid && elements.length > 0) {
1458+
return new LiteralArrayExpr(elements);
1459+
}
1460+
}
1461+
1462+
// Fallback to syntactic transform
1463+
return transformToTypeTupleElement(originalEl, reflector, diagnostics);
1464+
}
1465+
1466+
function transformToTypeTupleExpression(
1467+
expr: ts.Expression,
1468+
evaluator: PartialEvaluator,
1469+
refEmitter: ReferenceEmitter,
1470+
sourceFile: ts.SourceFile,
1471+
reflector: ReflectionHost,
1472+
diagnostics: ts.Diagnostic[],
1473+
): Expression | null {
1474+
if (ts.isArrayLiteralExpression(expr)) {
1475+
// An empty array is treated like an omitted slot (emitted as `never` by
1476+
// `createNgModuleType`), matching the standard compilation path.
1477+
if (expr.elements.length === 0) {
1478+
return null;
1479+
}
1480+
return new LiteralArrayExpr(
1481+
expr.elements.map((el) => {
1482+
const resolved = evaluator.evaluate(el);
1483+
return resolvedToTypeTupleElement(
1484+
el,
1485+
resolved,
1486+
refEmitter,
1487+
sourceFile,
1488+
reflector,
1489+
diagnostics,
1490+
);
1491+
}),
1492+
);
1493+
}
1494+
const resolved = evaluator.evaluate(expr);
1495+
return resolvedToTypeTupleElement(expr, resolved, refEmitter, sourceFile, reflector, diagnostics);
1496+
}

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
],

0 commit comments

Comments
 (0)