refactor(compiler): add support for host directives under isolatedDeclarations

Removes restrictions around using external references and local directives in `hostDirectives` under isolated declarations mode (`emitDeclarationOnly: true`).

By wrapping the host directive reference in a `WrappedNodeExpr`, TypeScript's declaration emitter seamlessly emits `typeof hostReference.node`, preserving existing imports or local identifiers exactly as authored. Also adds support for translating `PropertyAccessExpression` inside `WrappedNodeExpr` into `QualifiedName` for `.d.ts` emission, ensuring namespace imports (`import * as n from './dir'`) are preserved correctly.
This commit is contained in:
Alex Rickabaugh
2026-05-13 11:29:58 -07:00
committed by Jessica Janiuk
parent 0152e3cbdf
commit 5d2b1c4100
4 changed files with 149 additions and 86 deletions
@@ -2097,29 +2097,7 @@ function extractHostDirectives(
`In ${compilationModeName} mode, host directive cannot be an expression. Use an identifier instead`,
);
}
if (emitDeclarationOnly) {
if (ts.isIdentifier(hostReference.node)) {
const importInfo = reflector.getImportOfIdentifier(hostReference.node);
if (importInfo) {
directive = new ExternalReference(importInfo.from, importInfo.name);
} else {
throw new FatalDiagnosticError(
ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION,
hostReference.node,
`In experimental declaration-only emission mode, host directive cannot use indirect external indentifiers. Use a direct external identifier instead`,
);
}
} else {
throw new FatalDiagnosticError(
ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION,
hostReference.node,
`In experimental declaration-only emission mode, host directive cannot be an expression. Use an identifier instead`,
);
}
} else {
directive = new WrappedNodeExpr(hostReference.node);
}
directive = new WrappedNodeExpr(hostReference.node);
} else if (hostReference instanceof Reference) {
directive = hostReference as Reference<ClassDeclaration>;
nameForErrors = (fieldName: string) =>
@@ -252,6 +252,16 @@ class TypeTranslatorVisitor implements o.ExpressionVisitor, o.TypeVisitor {
const node: ts.Node = ast.node;
if (ts.isEntityName(node)) {
return ts.factory.createTypeReferenceNode(this.routeEntityNameThroughImportManager(node));
} else if (ts.isPropertyAccessExpression(node)) {
const entityName = expressionToEntityName(node);
if (entityName !== null) {
return ts.factory.createTypeReferenceNode(
this.routeEntityNameThroughImportManager(entityName),
);
}
throw new Error(
`Unsupported PropertyAccessExpression in TypeTranslatorVisitor: ${node.getText()} in ${node.getSourceFile()?.fileName}`,
);
} else if (ts.isTypeNode(node)) {
// The wrapped type node may reference identifiers from another source file (e.g. when the
// NgModule isolated-declarations transform synthesizes `ReturnType<typeof Foo.forRoot>`).
@@ -423,3 +433,14 @@ function replaceLeftmostEntityName(name: ts.EntityName, newLeftmost: ts.EntityNa
name.right,
);
}
function expressionToEntityName(expr: ts.Expression): ts.EntityName | null {
if (ts.isIdentifier(expr)) {
return ts.factory.createIdentifier(expr.text);
}
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.name)) {
const left = expressionToEntityName(expr.expression);
return left === null ? null : ts.factory.createQualifiedName(left, expr.name);
}
return null;
}
@@ -3,9 +3,7 @@
"cases": [
{
"description": "should create a basic hostDirectives definition",
"inputFiles": [
"basic_host_directives.ts"
],
"inputFiles": ["basic_host_directives.ts"],
"expectations": [
{
"failureMessage": "Incorrect definition",
@@ -16,13 +14,17 @@
}
]
}
],
"compilationModeFilter": [
"full compile",
"linked compile",
"local compile",
"declaration-only emit"
]
},
{
"description": "should create hostDirective definitions for a chain of host directives",
"inputFiles": [
"chained_host_directives.ts"
],
"inputFiles": ["chained_host_directives.ts"],
"expectations": [
{
"failureMessage": "Incorrect definition",
@@ -33,13 +35,17 @@
}
]
}
],
"compilationModeFilter": [
"full compile",
"linked compile",
"local compile",
"declaration-only emit"
]
},
{
"description": "should handle a forwardRef used in hostDirectives",
"inputFiles": [
"forward_ref_host_directives.ts"
],
"inputFiles": ["forward_ref_host_directives.ts"],
"expectations": [
{
"failureMessage": "Incorrect definition",
@@ -50,13 +56,17 @@
}
]
}
],
"compilationModeFilter": [
"full compile",
"linked compile",
"local compile",
"declaration-only emit"
]
},
{
"description": "should handle the `inputs` and `outputs` options in host directives",
"inputFiles": [
"host_directives_with_inputs_outputs.ts"
],
"inputFiles": ["host_directives_with_inputs_outputs.ts"],
"expectations": [
{
"failureMessage": "Incorrect definition",
@@ -67,13 +77,17 @@
}
]
}
],
"compilationModeFilter": [
"full compile",
"linked compile",
"local compile",
"declaration-only emit"
]
},
{
"description": "should handle aliases to aliased `inputs` and `outputs` of a host directive",
"inputFiles": [
"host_directives_with_host_aliases.ts"
],
"inputFiles": ["host_directives_with_host_aliases.ts"],
"expectations": [
{
"failureMessage": "Incorrect definition",
@@ -84,6 +98,12 @@
}
]
}
],
"compilationModeFilter": [
"full compile",
"linked compile",
"local compile",
"declaration-only emit"
]
}
]
@@ -445,7 +445,59 @@ runInEachFileSystem(() => {
);
});
it('should show correct error message when using an indirect external reference in a simple host directive on a component', () => {
it('should emit type declarations containing external reference via namespace import in host directive on a component', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
import * as n from './dir';
@Component({
template: '',
selector: 'host-comp',
hostDirectives: [n.Dir],
})
export class HostComp {}
`,
);
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(dtsContent).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<HostComp, "host-comp", never, {}, {}, never, never, true, [{ directive: typeof n.Dir; inputs: {}; outputs: {}; }]>;',
);
});
it('should emit type declarations containing external reference with inputs and outputs in host directive on a component', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
import {Dir} from './dir';
@Component({
template: '',
selector: 'host-comp',
hostDirectives: [{
directive: Dir,
inputs: ['a: b'],
outputs: ['c: d'],
}],
})
export class HostComp {}
`,
);
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(dtsContent).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<HostComp, "host-comp", never, {}, {}, never, never, true, [{ directive: typeof i1.Dir; inputs: { "a": "b"; }; outputs: { "c": "d"; }; }]>;',
);
});
it('should emit type declarations when using an indirect external reference in a simple host directive on a component', () => {
env.write(
'test.ts',
`
@@ -463,16 +515,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot use indirect external indentifiers. Use a direct external identifier instead',
expect(dtsContent).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<HostComp, "host-comp", never, {}, {}, never, never, true, [{ directive: typeof DirIndirect; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using an indirect external reference in host directive object on a component', () => {
it('should emit type declarations when using an indirect external reference in host directive object on a component', () => {
env.write(
'test.ts',
`
@@ -492,16 +543,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot use indirect external indentifiers. Use a direct external identifier instead',
expect(dtsContent).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<HostComp, "host-comp", never, {}, {}, never, never, true, [{ directive: typeof DirIndirect; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using an indirect external reference in a simple host directive on a directive', () => {
it('should emit type declarations when using an indirect external reference in a simple host directive on a directive', () => {
env.write(
'test.ts',
`
@@ -518,16 +568,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot use indirect external indentifiers. Use a direct external identifier instead',
expect(dtsContent).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<HostDir, "[host-dir]", never, {}, {}, never, never, true, [{ directive: typeof DirIndirect; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using an indirect external reference in host directive object on a directive', () => {
it('should emit type declarations when using an indirect external reference in host directive object on a directive', () => {
env.write(
'test.ts',
`
@@ -546,16 +595,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot use indirect external indentifiers. Use a direct external identifier instead',
expect(dtsContent).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<HostDir, "[host-dir]", never, {}, {}, never, never, true, [{ directive: typeof DirIndirect; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using a property access expression resolving to an indirect external reference in a simple host directive on a component', () => {
it('should emit type declarations when using a property access expression resolving to an indirect external reference in a simple host directive on a component', () => {
env.write(
'test.ts',
`
@@ -575,16 +623,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot be an expression. Use an identifier instead',
expect(dtsContent).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<HostComp, "host-comp", never, {}, {}, never, never, true, [{ directive: typeof DIR.Dir; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using a property access expression resolving to an indirect external reference in host directive object on a component', () => {
it('should emit type declarations when using a property access expression resolving to an indirect external reference in host directive object on a component', () => {
env.write(
'test.ts',
`
@@ -606,16 +653,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot be an expression. Use an identifier instead',
expect(dtsContent).toContain(
'static ɵcmp: i0.ɵɵComponentDeclaration<HostComp, "host-comp", never, {}, {}, never, never, true, [{ directive: typeof DIR.Dir; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using a property access expression resolving to an indirect external reference in a simple host directive on a directive', () => {
it('should emit type declarations when using a property access expression resolving to an indirect external reference in a simple host directive on a directive', () => {
env.write(
'test.ts',
`
@@ -634,16 +680,15 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot be an expression. Use an identifier instead',
expect(dtsContent).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<HostDir, "[host-dir]", never, {}, {}, never, never, true, [{ directive: typeof DIR.Dir; inputs: {}; outputs: {}; }]>;',
);
});
it('should show correct error message when using a property access expression resolving to an indirect external reference in host directive object on a directive', () => {
it('should emit type declarations when using a property access expression resolving to an indirect external reference in host directive object on a directive', () => {
env.write(
'test.ts',
`
@@ -664,12 +709,11 @@ runInEachFileSystem(() => {
`,
);
const errors = env.driveDiagnostics();
env.driveMain();
const dtsContent = env.getContents('test.d.ts');
expect(errors.length).toBe(1);
expect(errors[0].code).toBe(ngErrorCode(ErrorCode.LOCAL_COMPILATION_UNSUPPORTED_EXPRESSION));
expect(ts.flattenDiagnosticMessageText(errors[0].messageText, '\n')).toBe(
'In experimental declaration-only emission mode, host directive cannot be an expression. Use an identifier instead',
expect(dtsContent).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<HostDir, "[host-dir]", never, {}, {}, never, never, true, [{ directive: typeof DIR.Dir; inputs: {}; outputs: {}; }]>;',
);
});