Skip to content

useAngularCompilationAPI is incompatible with Vite's build.lib mode #2324

@tomer953

Description

@tomer953

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

  1. Create a Vite + Angular 20.1+ project using @analogjs/vite-plugin-angular@2.3.1.

  2. 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',
                },
            }),
        ],
    }));
  3. 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);
  4. Run vite build --mode production.

  5. Observe TS5051 on every @Component source file.

  6. (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

  • Yes
  • No

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent readyIssue ready for agent-assisted contributionbugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions