Please provide the environment you discovered this bug in.
@analogjs/vite-plugin-angular: 2.3.1
@angular/core / @angular/compiler-cli / @angular/build: 21.2.10
vite (rolldown-vite): 7.3.1
- TypeScript: 5.x (whatever
@angular/compiler-cli@21.2.x ships with)
Which area/package is the issue in?
vite-plugin-angular
Description
Hi Brandon, sorry in advance for copy-pasting my agent diagnostic, I'm currently not available to make a full repro, hope its fine just opening up this issue
Summary
Enabling experimental.useAngularCompilationAPI: true together with a vite.config.ts that uses Vite's library mode (build.lib) breaks the production build with two independent errors, both originating from the same plugin branch (angular-vite-plugin.js lines 591–595). The legacy (default) compilation path papers over both bugs; the new Angular Compilation API path does not.
The same project builds successfully when either:
experimental.useAngularCompilationAPI is omitted / set to false, or
build.lib is replaced with an equivalent rollupOptions.input + output.format: 'es' configuration (i.e. anything that leaves config.build.lib undefined).
Description
The plugin branch in question
// node_modules/@analogjs/vite-plugin-angular/src/lib/angular-vite-plugin.js
// (appears in BOTH performAngularCompilation (~line 591) and performCompilation (~line 681))
if (!isTest && config.build?.lib) {
tsCompilerOptions['declaration'] = true;
tsCompilerOptions['declarationMap'] = watchMode;
tsCompilerOptions['inlineSources'] = true;
}
This branch fires whenever Vite's build.lib is set. It mutates the in-memory TS compiler options after they have been read by compilerCli.readConfiguration, so the mutations escape the validation that readConfiguration performs.
Bug 1 — TS5051 on the new compilation API path
[plugin @analogjs/vite-plugin-angular] /path/to/app.component.ts
RollupError: TS5051: Option 'inlineSources' can only be used when either option '--inlineSourceMap' or option '--sourceMap' is provided.
Root cause
@angular/build's AngularCompilation.loadConfiguration (called from vite-plugin-angular's performAngularCompilation) passes the following overrides to compilerCli.readConfiguration:
{
suppressOutputPathCheck: true,
outDir: undefined,
sourceMap: false, // ← forces sourceMap off
declaration: false,
declarationMap: false,
allowEmptyCodegenFiles: false,
annotationsAs: 'decorators',
enableResourceInlining: false,
supportTestBed: false,
supportJitMode: false,
removeComments: false,
}
Notably, inlineSources and inlineSourceMap are NOT overridden. They are read from the user's tsconfig chain (typically unset → undefined).
After readConfiguration returns, vite-plugin-angular's compilerOptionsTransformer callback executes the lib-mode branch above, mutating tsCompilerOptions['inlineSources'] = true. By the time NgtscProgram (or @angular/build's downstream validators) re-checks the options, it sees:
sourceMap: false
inlineSourceMap: undefined
inlineSources: true ← unpaired
→ TypeScript throws TS5051.
Why the legacy path doesn't trip TS5051
The legacy path's compilerCli.readConfiguration call passes inlineSourceMap: !isProd, inlineSources: !isProd as overrides. In production both end up false and pass validation; the plugin then mutates inlineSources to true after validation, and TypeScript never re-checks. Functionally inconsistent with the new path, but the user-visible result is "no error".
Bug 2 — [PARSE_ERROR] Unexpected token on export declare class …
When you work around bug 1 (e.g. by adding inlineSourceMap: true and sourceMap: false in your tsconfig), the build progresses further and then fails with:
[PARSE_ERROR] Error: Unexpected token
╭─[ src/app/app.component.ts:2:8 ]
│
2 │ export declare class App {
│ ───┬───
│ ╰───
───╯
The contents of app.component.ts (the source file) is fine — but the parser is being fed a .d.ts file's contents instead.
Root cause
@angular/build's AotCompilation.emitAffectedFiles keys emitted outputs by the original source file:
const writeFileCallback = (filename, contents, _a, _b, sourceFiles) => {
/* ... */
const sourceFile = ts.getOriginalNode(sourceFiles[0], ts.isSourceFile);
/* ... */
emittedFiles.set(sourceFile, { filename: sourceFile.fileName, contents }); // ← last-write-wins by source file
};
When declaration: true is enabled (which the lib-mode branch above unconditionally sets), TypeScript emits multiple files per source (.js, possibly .js.map, and .d.ts). They all key into emittedFiles by the same sourceFile → the last emitted output wins. The .d.ts is emitted after the .js, so the .d.ts content overwrites the .js content.
Then in vite-plugin-angular's performAngularCompilation:
for (const file of await compilation.emitAffectedFiles()) {
outputFiles.set(file.filename, {
content: file.contents, // ← actually .d.ts content
dependencies: [],
errors: errors.map((error) => error.text || ''),
warnings: warnings.map((warning) => warning.text || ''),
});
}
Vite then asks the plugin to transform src/app/app.component.ts and gets back export declare class App { ... } — which Rolldown/Vite (correctly) refuses to parse as TypeScript source.
Why the legacy path doesn't have this bug
The legacy path's local writeFileCallback filters declaration files explicitly:
const writeFileCallback = (_filename, content, _a, _b, sourceFiles) => {
if (!sourceFiles?.length) return;
const filename = normalizePath(sourceFiles[0].fileName);
if (filename.includes('ngtypecheck.ts') || filename.includes('.d.')) {
return; // ← skips .d.ts and ngtypecheck.ts
}
/* ... */
};
The new compilation API path's ingestion loop has no equivalent filter.
Steps to reproduce
-
Create a Vite + Angular 20.1+ project using @analogjs/vite-plugin-angular@2.3.1.
-
Configure vite.config.ts to build a library bundle (typical for micro-frontend / module federation / library scenarios):
import angular from '@analogjs/vite-plugin-angular';
import {defineConfig} from 'vite';
import * as path from 'path';
export default defineConfig(({mode}) => ({
build: {
lib: {
entry: path.resolve(__dirname, 'src/mount.ts'),
formats: ['es'],
fileName: () => 'main.js',
},
},
plugins: [
angular({
tsconfig: path.resolve(__dirname, 'tsconfig.app.json'),
jit: mode !== 'production',
experimental: {
useAngularCompilationAPI: mode === 'production',
},
}),
],
}));
-
Have an mount.ts that imports a couple of Angular @Components:
import 'zone.js';
import '@angular/compiler';
import {bootstrapApplication} from '@angular/platform-browser';
import {App} from './app/app.component';
import {appConfig} from './app/app.config';
export const mount = (host: HTMLElement) => bootstrapApplication(App, appConfig);
-
Run vite build --mode production.
-
Observe TS5051 on every @Component source file.
-
(Optional) Add "inlineSourceMap": true, "sourceMap": false to tsconfig.app.json to bypass bug 1, then re-run. Observe [PARSE_ERROR] Unexpected token on export declare class … for every component.
Expected behavior
useAngularCompilationAPI: true should produce a working production build for Vite library-mode projects, just as the legacy compilation path does.
Actual behavior
Build fails with TS5051 (always) and, after bypassing TS5051, with [PARSE_ERROR] Unexpected token on export declare class ….
Workarounds available today
- Disable
experimental.useAngularCompilationAPI (loses the proper template diagnostics that motivated turning it on).
- Replace
build.lib with rollupOptions.input + output.format: 'es' + output.entryFileNames: 'main.js' + preserveEntrySignatures: 'exports-only'. This produces an equivalent ESM bundle but does not set config.build.lib, so the toxic plugin branch is skipped. (This is what we adopted in our codebase.)
Both workarounds are awkward; the underlying plugin code path should just work.
Proposed fixes
Fix 1 — pair inlineSources with a sourcemap option (or skip it)
In angular-vite-plugin.ts (lib-mode branch in both performAngularCompilation and performCompilation):
if (!isTest && config.build?.lib) {
tsCompilerOptions['declaration'] = true;
tsCompilerOptions['declarationMap'] = watchMode;
- tsCompilerOptions['inlineSources'] = true;
+ // `inlineSources` requires a sourcemap option to be set, otherwise TS5051 fires
+ // (see https://github.com/microsoft/TypeScript/issues/...). Only enable it when one is.
+ if (tsCompilerOptions['inlineSourceMap'] || tsCompilerOptions['sourceMap']) {
+ tsCompilerOptions['inlineSources'] = true;
+ }
}
Fix 2 — filter .d.ts / .ngtypecheck.ts when ingesting emitAffectedFiles()
In performAngularCompilation (around line 610–617):
for (const file of await compilation.emitAffectedFiles()) {
+ // The new compilation API path has no equivalent of the legacy path's writeFileCallback
+ // filtering. Without this, .d.ts emitted by `declaration: true` (set above for lib mode)
+ // overwrites the .js content because @angular/build's emitAffectedFiles keys outputs by
+ // source file (last-write-wins).
+ if (file.filename.endsWith('.d.ts') || file.filename.endsWith('.d.cts') ||
+ file.filename.endsWith('.d.mts') || file.filename.includes('.ngtypecheck.')) {
+ continue;
+ }
outputFiles.set(file.filename, {
content: file.contents,
dependencies: [],
errors: errors.map((error) => error.text || ''),
warnings: warnings.map((warning) => warning.text || ''),
});
}
Each fix in isolation only addresses one bug; both are needed for useAngularCompilationAPI + build.lib to work end-to-end.
Why this matters
build.lib mode is the standard Vite configuration for:
- Micro-frontend bundles consumed via importmap or Module Federation.
- Publishable Angular libraries using Vite (instead of ng-packagr).
- Web component / custom-element distributions.
All of these break under useAngularCompilationAPI: true. The new API is otherwise a clear improvement (reports template NG diagnostics that the legacy path silently swallows in build mode), so it would be valuable to make it compatible with library builds.
Please provide the exception or error you saw
Other information
No response
I would be willing to submit a PR to fix this issue
Please provide the environment you discovered this bug in.
@analogjs/vite-plugin-angular: 2.3.1@angular/core/@angular/compiler-cli/@angular/build: 21.2.10vite(rolldown-vite): 7.3.1@angular/compiler-cli@21.2.xships with)Which area/package is the issue in?
vite-plugin-angular
Description
Hi Brandon, sorry in advance for copy-pasting my agent diagnostic, I'm currently not available to make a full repro, hope its fine just opening up this issue
Summary
Enabling
experimental.useAngularCompilationAPI: truetogether with avite.config.tsthat uses Vite's library mode (build.lib) breaks the production build with two independent errors, both originating from the same plugin branch (angular-vite-plugin.jslines 591–595). The legacy (default) compilation path papers over both bugs; the new Angular Compilation API path does not.The same project builds successfully when either:
experimental.useAngularCompilationAPIis omitted / set tofalse, orbuild.libis replaced with an equivalentrollupOptions.input+output.format: 'es'configuration (i.e. anything that leavesconfig.build.libundefined).Description
The plugin branch in question
This branch fires whenever Vite's
build.libis set. It mutates the in-memory TS compiler options after they have been read bycompilerCli.readConfiguration, so the mutations escape the validation thatreadConfigurationperforms.Bug 1 —
TS5051on the new compilation API pathRoot cause
@angular/build'sAngularCompilation.loadConfiguration(called fromvite-plugin-angular'sperformAngularCompilation) passes the following overrides tocompilerCli.readConfiguration:Notably,
inlineSourcesandinlineSourceMapare NOT overridden. They are read from the user's tsconfig chain (typically unset →undefined).After
readConfigurationreturns,vite-plugin-angular'scompilerOptionsTransformercallback executes the lib-mode branch above, mutatingtsCompilerOptions['inlineSources'] = true. By the timeNgtscProgram(or@angular/build's downstream validators) re-checks the options, it sees:sourceMap: falseinlineSourceMap: undefinedinlineSources: true← unpaired→ TypeScript throws TS5051.
Why the legacy path doesn't trip TS5051
The legacy path's
compilerCli.readConfigurationcall passesinlineSourceMap: !isProd, inlineSources: !isProdas overrides. In production both end upfalseand pass validation; the plugin then mutatesinlineSourcestotrueafter validation, and TypeScript never re-checks. Functionally inconsistent with the new path, but the user-visible result is "no error".Bug 2 —
[PARSE_ERROR] Unexpected tokenonexport declare class …When you work around bug 1 (e.g. by adding
inlineSourceMap: trueandsourceMap: falsein your tsconfig), the build progresses further and then fails with:The contents of
app.component.ts(the source file) is fine — but the parser is being fed a.d.tsfile's contents instead.Root cause
@angular/build'sAotCompilation.emitAffectedFileskeys emitted outputs by the original source file:When
declaration: trueis enabled (which the lib-mode branch above unconditionally sets), TypeScript emits multiple files per source (.js, possibly.js.map, and.d.ts). They all key intoemittedFilesby the samesourceFile→ the last emitted output wins. The.d.tsis emitted after the.js, so the.d.tscontent overwrites the.jscontent.Then in
vite-plugin-angular'sperformAngularCompilation:Vite then asks the plugin to transform
src/app/app.component.tsand gets backexport declare class App { ... }— which Rolldown/Vite (correctly) refuses to parse as TypeScript source.Why the legacy path doesn't have this bug
The legacy path's local
writeFileCallbackfilters declaration files explicitly:The new compilation API path's ingestion loop has no equivalent filter.
Steps to reproduce
Create a Vite + Angular 20.1+ project using
@analogjs/vite-plugin-angular@2.3.1.Configure
vite.config.tsto build a library bundle (typical for micro-frontend / module federation / library scenarios):Have an
mount.tsthat imports a couple of Angular@Components:Run
vite build --mode production.Observe TS5051 on every
@Componentsource file.(Optional) Add
"inlineSourceMap": true, "sourceMap": falsetotsconfig.app.jsonto bypass bug 1, then re-run. Observe[PARSE_ERROR] Unexpected tokenonexport declare class …for every component.Expected behavior
useAngularCompilationAPI: trueshould produce a working production build for Vite library-mode projects, just as the legacy compilation path does.Actual behavior
Build fails with TS5051 (always) and, after bypassing TS5051, with
[PARSE_ERROR] Unexpected tokenonexport declare class ….Workarounds available today
experimental.useAngularCompilationAPI(loses the proper template diagnostics that motivated turning it on).build.libwithrollupOptions.input+output.format: 'es'+output.entryFileNames: 'main.js'+preserveEntrySignatures: 'exports-only'. This produces an equivalent ESM bundle but does not setconfig.build.lib, so the toxic plugin branch is skipped. (This is what we adopted in our codebase.)Both workarounds are awkward; the underlying plugin code path should just work.
Proposed fixes
Fix 1 — pair
inlineSourceswith a sourcemap option (or skip it)In
angular-vite-plugin.ts(lib-mode branch in bothperformAngularCompilationandperformCompilation):if (!isTest && config.build?.lib) { tsCompilerOptions['declaration'] = true; tsCompilerOptions['declarationMap'] = watchMode; - tsCompilerOptions['inlineSources'] = true; + // `inlineSources` requires a sourcemap option to be set, otherwise TS5051 fires + // (see https://github.com/microsoft/TypeScript/issues/...). Only enable it when one is. + if (tsCompilerOptions['inlineSourceMap'] || tsCompilerOptions['sourceMap']) { + tsCompilerOptions['inlineSources'] = true; + } }Fix 2 — filter
.d.ts/.ngtypecheck.tswhen ingestingemitAffectedFiles()In
performAngularCompilation(around line 610–617):for (const file of await compilation.emitAffectedFiles()) { + // The new compilation API path has no equivalent of the legacy path's writeFileCallback + // filtering. Without this, .d.ts emitted by `declaration: true` (set above for lib mode) + // overwrites the .js content because @angular/build's emitAffectedFiles keys outputs by + // source file (last-write-wins). + if (file.filename.endsWith('.d.ts') || file.filename.endsWith('.d.cts') || + file.filename.endsWith('.d.mts') || file.filename.includes('.ngtypecheck.')) { + continue; + } outputFiles.set(file.filename, { content: file.contents, dependencies: [], errors: errors.map((error) => error.text || ''), warnings: warnings.map((warning) => warning.text || ''), }); }Each fix in isolation only addresses one bug; both are needed for
useAngularCompilationAPI+build.libto work end-to-end.Why this matters
build.libmode is the standard Vite configuration for:All of these break under
useAngularCompilationAPI: true. The new API is otherwise a clear improvement (reports template NG diagnostics that the legacy path silently swallows in build mode), so it would be valuable to make it compatible with library builds.Please provide the exception or error you saw
Other information
No response
I would be willing to submit a PR to fix this issue