diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md
index c5bf62d04..d8c058a26 100644
--- a/napi/angular-compiler/README.md
+++ b/napi/angular-compiler/README.md
@@ -198,7 +198,8 @@ interface PluginOptions {
// Project configuration
tsconfig?: string
workspaceRoot?: string
- // Application's Angular version, used for app code and linked libraries (default: latest)
+ // Application's Angular version, used for app code and linked libraries
+ // (default: the installed @angular/core version, else latest)
angularVersion?: { major: number; minor: number; patch: number }
// Features
diff --git a/napi/angular-compiler/test/angular-version.test.ts b/napi/angular-compiler/test/angular-version.test.ts
new file mode 100644
index 000000000..5d70e0134
--- /dev/null
+++ b/napi/angular-compiler/test/angular-version.test.ts
@@ -0,0 +1,166 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { normalizePath } from 'vite'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { angular, type PluginOptions } from '../vite-plugin/index.js'
+import {
+ installedAngularVersion,
+ parseAngularVersion,
+} from '../vite-plugin/utils/angular-version.js'
+
+const dirs: string[] = []
+
+/** A project directory with `@angular/core` at `version` installed, if any. */
+function project(version?: string): string {
+ const dir = mkdtempSync(join(tmpdir(), 'angular-version-'))
+ dirs.push(dir)
+ if (version) install(dir, version)
+ return dir
+}
+
+/** `@angular/core` at `version` in `dir/node_modules`. */
+function install(dir: string, version: string): void {
+ const core = join(dir, 'node_modules/@angular/core')
+ mkdirSync(core, { recursive: true })
+ writeFileSync(join(core, 'package.json'), JSON.stringify({ name: '@angular/core', version }))
+}
+
+afterEach(() => {
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
+describe('parseAngularVersion', () => {
+ it('reads stable and prerelease versions', () => {
+ expect(parseAngularVersion('22.1.3')).toEqual({ major: 22, minor: 1, patch: 3 })
+ expect(parseAngularVersion('22.2.0-next.3')).toEqual({
+ major: 22,
+ minor: 2,
+ patch: 0,
+ prerelease: 'next.3',
+ })
+ expect(parseAngularVersion('21.0.0+sha-abc')).toEqual({ major: 21, minor: 0, patch: 0 })
+ })
+
+ it("leaves out a build of Angular's own repository and anything else", () => {
+ expect(parseAngularVersion('0.0.0-PLACEHOLDER')).toBeUndefined()
+ expect(parseAngularVersion('22')).toBeUndefined()
+ expect(parseAngularVersion(undefined)).toBeUndefined()
+ })
+})
+
+describe('installedAngularVersion', () => {
+ it('finds @angular/core from a nested directory', () => {
+ const root = project('21.2.1')
+ const nested = join(root, 'apps/app')
+ mkdirSync(nested, { recursive: true })
+ expect(installedAngularVersion(nested)).toEqual({ major: 21, minor: 2, patch: 1 })
+ })
+
+ it('resolves a relative root first', () => {
+ // `.` in an app whose packages are hoisted to the workspace above it.
+ const root = project('21.2.1')
+ const nested = join(root, 'apps/app')
+ mkdirSync(nested, { recursive: true })
+ const cwd = process.cwd()
+ process.chdir(nested)
+ try {
+ expect(installedAngularVersion('.')).toEqual({ major: 21, minor: 2, patch: 1 })
+ } finally {
+ process.chdir(cwd)
+ }
+ })
+
+ it('gives undefined without a readable package', () => {
+ const root = project()
+ mkdirSync(join(root, 'node_modules/@angular/core'), { recursive: true })
+ writeFileSync(join(root, 'node_modules/@angular/core/package.json'), '{')
+ expect(installedAngularVersion(root)).toBeUndefined()
+ })
+})
+
+const SOURCE = `import { Component } from '@angular/core';
+import { Cmp } from './cmp';
+
+@Component({
+ selector: 'app-root',
+ template: '@defer { }',
+ deferredImports: [Cmp],
+})
+export class AppComponent {}
+`
+
+/** The plugin's output for `SOURCE` in `root`, or the error it reports. */
+async function transform(options: PluginOptions, root: string): Promise {
+ const plugin = angular(options).find((candidate) => candidate.name === '@oxc-angular/vite')
+ if (!plugin?.transform || typeof plugin.transform === 'function') {
+ throw new Error('Expected plugin transform handler')
+ }
+ const configResolved = plugin.configResolved as unknown as
+ | { handler: (config: unknown) => void }
+ | ((config: unknown) => void)
+ const config = { root, build: {}, isProduction: false }
+ if (typeof configResolved === 'function') configResolved(config)
+ else configResolved?.handler(config)
+ try {
+ const result = await plugin.transform.handler.call(
+ {
+ error(message: string) {
+ throw new Error(message)
+ },
+ warn() {},
+ } as any,
+ SOURCE,
+ normalizePath(join(root, 'src/app.component.ts')),
+ )
+ return (result as { code: string }).code
+ } catch (error) {
+ return `ERROR ${(error as Error).message}`
+ }
+}
+
+describe('angularVersion default', () => {
+ it('compiles the array deferredImports of an Angular 22.1 project', async () => {
+ const root = project('22.1.0')
+ const code = await transform({ workspaceRoot: root }, root)
+ expect(code).not.toContain('ERROR')
+ expect(code).toContain('import("./cmp")')
+ })
+
+ it('reports the array deferredImports of an Angular 22.2 project', async () => {
+ const root = project('22.2.0')
+ const code = await transform({ workspaceRoot: root }, root)
+ expect(code).toContain("'deferredImports' must be an object mapping")
+ })
+
+ it('prefers the angularVersion option over the installed version', async () => {
+ const root = project('22.2.0')
+ const code = await transform(
+ { workspaceRoot: root, angularVersion: { major: 22, minor: 1, patch: 0 } },
+ root,
+ )
+ expect(code).not.toContain('ERROR')
+ })
+
+ it("reads the version installed for Vite's root, not the workspace root", async () => {
+ // An app with its own `@angular/core` in a workspace on a newer one.
+ const workspace = project('22.2.0')
+ const app = join(workspace, 'apps/old')
+ install(app, '22.1.0')
+ const code = await transform({ workspaceRoot: workspace }, app)
+ expect(code).not.toContain('ERROR')
+ })
+
+ it('links dependencies for the same version', async () => {
+ const workspace = project('22.2.0')
+ const app = join(workspace, 'apps/old')
+ install(app, '22.1.0')
+ const linker = angular({ workspaceRoot: workspace }).find(
+ (candidate) => candidate.name === '@oxc-angular/vite-linker',
+ ) as any
+ const config = linker.config({ root: app }, { command: 'build', mode: 'production' })
+ expect(config.optimizeDeps.rolldownOptions.plugins[0].name).toBe('angular-linker@22.1.0')
+ })
+})
diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts
index 09c7fedf4..dbf02af97 100644
--- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts
+++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts
@@ -1,3 +1,5 @@
+import { resolve } from 'node:path'
+
import { linkAngularPackage, type AngularVersion } from '#binding'
import type { Plugin, ResolvedConfig } from 'vite'
@@ -100,8 +102,11 @@ async function linkCode(
}
export interface AngularLinkerPluginOptions {
- /** Angular version of the application, forwarded to the linker. Assumes latest when unset. */
- angularVersion?: AngularVersion
+ /**
+ * Angular version of the application, forwarded to the linker, or a function
+ * giving it for Vite's root. Assumes latest when unset.
+ */
+ angularVersion?: AngularVersion | ((root: string) => AngularVersion | undefined)
/** Return source maps from linked code back to the package file. Default: false */
sourcemap?: boolean
}
@@ -124,10 +129,15 @@ function optimizerPluginName(
}
export function angularLinkerPlugin({
- angularVersion,
+ angularVersion: angularVersionOption,
sourcemap = false,
}: AngularLinkerPluginOptions = {}): Plugin {
let optimizeDepsExclude: readonly string[] = []
+ // Recomputed for the config's root in `config`.
+ let angularVersion =
+ typeof angularVersionOption === 'function'
+ ? angularVersionOption(process.cwd())
+ : angularVersionOption
return {
name: '@oxc-angular/vite-linker',
@@ -137,7 +147,13 @@ export function angularLinkerPlugin({
optimizeDepsExclude = config.optimizeDeps?.exclude ?? []
},
- config(_, { command }) {
+ config(userConfig, { command }) {
+ if (typeof angularVersionOption === 'function') {
+ // The root Vite resolves the config with (`resolveConfig`).
+ angularVersion = angularVersionOption(
+ userConfig.root ? resolve(userConfig.root) : process.cwd(),
+ )
+ }
return {
optimizeDeps: {
rolldownOptions: {
diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts
index 9deb9cc66..c2f80f7bc 100644
--- a/napi/angular-compiler/vite-plugin/index.ts
+++ b/napi/angular-compiler/vite-plugin/index.ts
@@ -36,6 +36,7 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js'
import { jitPlugin } from './angular-jit-plugin.js'
import { angularLinkerPlugin } from './angular-linker-plugin.js'
import { ssrManifestPlugin } from './angular-ssr-manifest-plugin.js'
+import { installedAngularVersion } from './utils/angular-version.js'
import { injectDtsDeclarations } from './utils/dts.js'
/**
@@ -103,7 +104,10 @@ export interface PluginOptions {
*
* Also used when linking partially compiled libraries (`ɵɵngDeclare*`).
*
- * When not set, assumes latest Angular version (v20+ behavior).
+ * Default: the version of `@angular/core` installed for Vite's `root`
+ * (found in `node_modules` from there up), like the package the app
+ * imports. When none is found, or it is a `0.0.0-...` build, the latest
+ * Angular version is assumed.
*
* @example
* ```ts
@@ -322,6 +326,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// object form turns them on; `sourceMap: true` only covers scripts.
vendorSourceMap: typeof options.sourceMap === 'object' && (options.sourceMap.vendor ?? false),
fileReplacements,
+ // Without the option, the installed version once Vite's root is known.
angularVersion: options.angularVersion,
emitClassMetadata: options.emitClassMetadata ?? true,
compilationMode: options.compilationMode ?? 'full',
@@ -330,6 +335,17 @@ export function angular(options: PluginOptions = {}): Plugin[] {
tsconfig: options.tsconfig,
}
+ // The `angularVersion` option, else the `@angular/core` version installed
+ // for the Vite root `root` (looked up once per root).
+ let installed: { root: string; version: AngularVersion | undefined } | undefined
+ function angularVersionFor(root: string): AngularVersion | undefined {
+ if (options.angularVersion) return options.angularVersion
+ if (!installed || installed.root !== root) {
+ installed = { root, version: installedAngularVersion(root) }
+ }
+ return installed.version
+ }
+
let resolvedConfig: ResolvedConfig
let viteServer: ViteDevServer | undefined
let watchMode = false
@@ -743,6 +759,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
},
configResolved(config) {
resolvedConfig = config
+ pluginOptions.angularVersion = angularVersionFor(config.root ?? workspaceRoot)
},
outputOptions(options) {
outputMinify = options.minify
@@ -1724,7 +1741,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
angularPlugin(),
dtsPlugin(),
angularLinkerPlugin({
- angularVersion: pluginOptions.angularVersion,
+ angularVersion: angularVersionFor,
// The linker only sees node_modules files, so it follows the vendor setting
// (Angular CLI only maps third-party code when both scripts and vendor are on).
sourcemap: pluginOptions.sourceMap && pluginOptions.vendorSourceMap,
diff --git a/napi/angular-compiler/vite-plugin/utils/angular-version.ts b/napi/angular-compiler/vite-plugin/utils/angular-version.ts
new file mode 100644
index 000000000..8cb231c01
--- /dev/null
+++ b/napi/angular-compiler/vite-plugin/utils/angular-version.ts
@@ -0,0 +1,57 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { dirname, join, resolve } from 'node:path'
+
+import type { AngularVersion } from '#binding'
+
+/**
+ * The version of `@angular/core` installed for the project at `root` (Vite's
+ * root), the default of the `angularVersion` option. `undefined` when it isn't found or
+ * is a build of Angular's own repository (`0.0.0-...`): the compiler then
+ * targets the latest version.
+ */
+export function installedAngularVersion(root: string): AngularVersion | undefined {
+ // A relative `dirname` chain would stop at `.`.
+ const file = findAngularCorePackageJson(resolve(root))
+ if (!file) return undefined
+ try {
+ return parseAngularVersion(JSON.parse(readFileSync(file, 'utf8')).version)
+ } catch {
+ return undefined
+ }
+}
+
+/** `22.1.0-next.3` as an `AngularVersion`; build metadata (`+...`) is dropped. */
+export function parseAngularVersion(version: unknown): AngularVersion | undefined {
+ if (typeof version !== 'string') return undefined
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(version.trim())
+ if (!match) return undefined
+ const [, major, minor, patch, prerelease] = match
+ if (Number(major) === 0) return undefined
+ return {
+ major: Number(major),
+ minor: Number(minor),
+ patch: Number(patch),
+ ...(prerelease ? { prerelease } : {}),
+ }
+}
+
+/**
+ * Looked up in `node_modules` rather than with `require.resolve`, which also
+ * follows `NODE_PATH` (set by pnpm to its hoisted store), like zone.js in
+ * `angular-async-downlevel-plugin.ts`.
+ */
+function findAngularCorePackageJson(root: string): string | undefined {
+ if (process.versions['pnp']) {
+ try {
+ return createRequire(join(root, 'package.json')).resolve('@angular/core/package.json')
+ } catch {
+ return undefined
+ }
+ }
+ for (let dir = root; ; dir = dirname(dir)) {
+ const file = join(dir, 'node_modules/@angular/core/package.json')
+ if (existsSync(file)) return file
+ if (dirname(dir) === dir) return undefined
+ }
+}