Skip to content

Commit 631ea32

Browse files
Brooooooklynclaude
andauthored
feat(vite): default angularVersion to the installed @angular/core version (#672)
* feat(vite): default angularVersion to the installed @angular/core version Without the option the compiler targeted the latest Angular, so an Angular 22.1 project got error 1010 for the array form of `deferredImports`, which its runtime still expects. The plugin now reads @angular/core's version from node_modules, up from `workspaceRoot`; a `0.0.0-...` build or no package keeps the latest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vite): look up the installed Angular version from Vite's root The app's own @angular/core can sit under Vite's root in a workspace that hoists another version, and a relative path stopped the upward search at `.`. The linker now reads the version for the same root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 89dd9c6 commit 631ea32

5 files changed

Lines changed: 264 additions & 7 deletions

File tree

‎napi/angular-compiler/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,8 @@ interface PluginOptions {
204204
// Project configuration
205205
tsconfig?: string
206206
workspaceRoot?: string
207-
// Application's Angular version, used for app code and linked libraries (default: latest)
207+
// Application's Angular version, used for app code and linked libraries
208+
// (default: the installed @angular/core version, else latest)
208209
angularVersion?: { major: number; minor: number; patch: number }
209210

210211
// Features
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
5+
import { normalizePath } from 'vite'
6+
import { afterEach, describe, expect, it } from 'vitest'
7+
8+
import { angular, type PluginOptions } from '../vite-plugin/index.js'
9+
import {
10+
installedAngularVersion,
11+
parseAngularVersion,
12+
} from '../vite-plugin/utils/angular-version.js'
13+
14+
const dirs: string[] = []
15+
16+
/** A project directory with `@angular/core` at `version` installed, if any. */
17+
function project(version?: string): string {
18+
const dir = mkdtempSync(join(tmpdir(), 'angular-version-'))
19+
dirs.push(dir)
20+
if (version) install(dir, version)
21+
return dir
22+
}
23+
24+
/** `@angular/core` at `version` in `dir/node_modules`. */
25+
function install(dir: string, version: string): void {
26+
const core = join(dir, 'node_modules/@angular/core')
27+
mkdirSync(core, { recursive: true })
28+
writeFileSync(join(core, 'package.json'), JSON.stringify({ name: '@angular/core', version }))
29+
}
30+
31+
afterEach(() => {
32+
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
33+
})
34+
35+
describe('parseAngularVersion', () => {
36+
it('reads stable and prerelease versions', () => {
37+
expect(parseAngularVersion('22.1.3')).toEqual({ major: 22, minor: 1, patch: 3 })
38+
expect(parseAngularVersion('22.2.0-next.3')).toEqual({
39+
major: 22,
40+
minor: 2,
41+
patch: 0,
42+
prerelease: 'next.3',
43+
})
44+
expect(parseAngularVersion('21.0.0+sha-abc')).toEqual({ major: 21, minor: 0, patch: 0 })
45+
})
46+
47+
it("leaves out a build of Angular's own repository and anything else", () => {
48+
expect(parseAngularVersion('0.0.0-PLACEHOLDER')).toBeUndefined()
49+
expect(parseAngularVersion('22')).toBeUndefined()
50+
expect(parseAngularVersion(undefined)).toBeUndefined()
51+
})
52+
})
53+
54+
describe('installedAngularVersion', () => {
55+
it('finds @angular/core from a nested directory', () => {
56+
const root = project('21.2.1')
57+
const nested = join(root, 'apps/app')
58+
mkdirSync(nested, { recursive: true })
59+
expect(installedAngularVersion(nested)).toEqual({ major: 21, minor: 2, patch: 1 })
60+
})
61+
62+
it('resolves a relative root first', () => {
63+
// `.` in an app whose packages are hoisted to the workspace above it.
64+
const root = project('21.2.1')
65+
const nested = join(root, 'apps/app')
66+
mkdirSync(nested, { recursive: true })
67+
const cwd = process.cwd()
68+
process.chdir(nested)
69+
try {
70+
expect(installedAngularVersion('.')).toEqual({ major: 21, minor: 2, patch: 1 })
71+
} finally {
72+
process.chdir(cwd)
73+
}
74+
})
75+
76+
it('gives undefined without a readable package', () => {
77+
const root = project()
78+
mkdirSync(join(root, 'node_modules/@angular/core'), { recursive: true })
79+
writeFileSync(join(root, 'node_modules/@angular/core/package.json'), '{')
80+
expect(installedAngularVersion(root)).toBeUndefined()
81+
})
82+
})
83+
84+
const SOURCE = `import { Component } from '@angular/core';
85+
import { Cmp } from './cmp';
86+
87+
@Component({
88+
selector: 'app-root',
89+
template: '@defer { <cmp-a /> }',
90+
deferredImports: [Cmp],
91+
})
92+
export class AppComponent {}
93+
`
94+
95+
/** The plugin's output for `SOURCE` in `root`, or the error it reports. */
96+
async function transform(options: PluginOptions, root: string): Promise<string> {
97+
const plugin = angular(options).find((candidate) => candidate.name === '@oxc-angular/vite')
98+
if (!plugin?.transform || typeof plugin.transform === 'function') {
99+
throw new Error('Expected plugin transform handler')
100+
}
101+
const configResolved = plugin.configResolved as unknown as
102+
| { handler: (config: unknown) => void }
103+
| ((config: unknown) => void)
104+
const config = { root, build: {}, isProduction: false }
105+
if (typeof configResolved === 'function') configResolved(config)
106+
else configResolved?.handler(config)
107+
try {
108+
const result = await plugin.transform.handler.call(
109+
{
110+
error(message: string) {
111+
throw new Error(message)
112+
},
113+
warn() {},
114+
} as any,
115+
SOURCE,
116+
normalizePath(join(root, 'src/app.component.ts')),
117+
)
118+
return (result as { code: string }).code
119+
} catch (error) {
120+
return `ERROR ${(error as Error).message}`
121+
}
122+
}
123+
124+
describe('angularVersion default', () => {
125+
it('compiles the array deferredImports of an Angular 22.1 project', async () => {
126+
const root = project('22.1.0')
127+
const code = await transform({ workspaceRoot: root }, root)
128+
expect(code).not.toContain('ERROR')
129+
expect(code).toContain('import("./cmp")')
130+
})
131+
132+
it('reports the array deferredImports of an Angular 22.2 project', async () => {
133+
const root = project('22.2.0')
134+
const code = await transform({ workspaceRoot: root }, root)
135+
expect(code).toContain("'deferredImports' must be an object mapping")
136+
})
137+
138+
it('prefers the angularVersion option over the installed version', async () => {
139+
const root = project('22.2.0')
140+
const code = await transform(
141+
{ workspaceRoot: root, angularVersion: { major: 22, minor: 1, patch: 0 } },
142+
root,
143+
)
144+
expect(code).not.toContain('ERROR')
145+
})
146+
147+
it("reads the version installed for Vite's root, not the workspace root", async () => {
148+
// An app with its own `@angular/core` in a workspace on a newer one.
149+
const workspace = project('22.2.0')
150+
const app = join(workspace, 'apps/old')
151+
install(app, '22.1.0')
152+
const code = await transform({ workspaceRoot: workspace }, app)
153+
expect(code).not.toContain('ERROR')
154+
})
155+
156+
it('links dependencies for the same version', async () => {
157+
const workspace = project('22.2.0')
158+
const app = join(workspace, 'apps/old')
159+
install(app, '22.1.0')
160+
const linker = angular({ workspaceRoot: workspace }).find(
161+
(candidate) => candidate.name === '@oxc-angular/vite-linker',
162+
) as any
163+
const config = linker.config({ root: app }, { command: 'build', mode: 'production' })
164+
expect(config.optimizeDeps.rolldownOptions.plugins[0].name).toBe('angular-linker@22.1.0')
165+
})
166+
})

‎napi/angular-compiler/vite-plugin/angular-linker-plugin.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { resolve } from 'node:path'
2+
13
import { linkAngularPackage, type AngularVersion } from '#binding'
24
import type { Plugin, ResolvedConfig } from 'vite'
35

@@ -100,8 +102,11 @@ async function linkCode(
100102
}
101103

102104
export interface AngularLinkerPluginOptions {
103-
/** Angular version of the application, forwarded to the linker. Assumes latest when unset. */
104-
angularVersion?: AngularVersion
105+
/**
106+
* Angular version of the application, forwarded to the linker, or a function
107+
* giving it for Vite's root. Assumes latest when unset.
108+
*/
109+
angularVersion?: AngularVersion | ((root: string) => AngularVersion | undefined)
105110
/** Return source maps from linked code back to the package file. Default: false */
106111
sourcemap?: boolean
107112
}
@@ -124,10 +129,15 @@ function optimizerPluginName(
124129
}
125130

126131
export function angularLinkerPlugin({
127-
angularVersion,
132+
angularVersion: angularVersionOption,
128133
sourcemap = false,
129134
}: AngularLinkerPluginOptions = {}): Plugin {
130135
let optimizeDepsExclude: readonly string[] = []
136+
// Recomputed for the config's root in `config`.
137+
let angularVersion =
138+
typeof angularVersionOption === 'function'
139+
? angularVersionOption(process.cwd())
140+
: angularVersionOption
131141

132142
return {
133143
name: '@oxc-angular/vite-linker',
@@ -137,7 +147,13 @@ export function angularLinkerPlugin({
137147
optimizeDepsExclude = config.optimizeDeps?.exclude ?? []
138148
},
139149

140-
config(_, { command }) {
150+
config(userConfig, { command }) {
151+
if (typeof angularVersionOption === 'function') {
152+
// The root Vite resolves the config with (`resolveConfig`).
153+
angularVersion = angularVersionOption(
154+
userConfig.root ? resolve(userConfig.root) : process.cwd(),
155+
)
156+
}
141157
return {
142158
optimizeDeps: {
143159
rolldownOptions: {

‎napi/angular-compiler/vite-plugin/index.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js'
3636
import { jitPlugin } from './angular-jit-plugin.js'
3737
import { angularLinkerPlugin } from './angular-linker-plugin.js'
3838
import { ssrManifestPlugin } from './angular-ssr-manifest-plugin.js'
39+
import { installedAngularVersion } from './utils/angular-version.js'
3940
import { injectDtsDeclarations } from './utils/dts.js'
4041

4142
/**
@@ -103,7 +104,10 @@ export interface PluginOptions {
103104
*
104105
* Also used when linking partially compiled libraries (`ɵɵngDeclare*`).
105106
*
106-
* When not set, assumes latest Angular version (v20+ behavior).
107+
* Default: the version of `@angular/core` installed for Vite's `root`
108+
* (found in `node_modules` from there up), like the package the app
109+
* imports. When none is found, or it is a `0.0.0-...` build, the latest
110+
* Angular version is assumed.
107111
*
108112
* @example
109113
* ```ts
@@ -352,6 +356,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
352356
// object form turns them on; `sourceMap: true` only covers scripts.
353357
vendorSourceMap: typeof options.sourceMap === 'object' && (options.sourceMap.vendor ?? false),
354358
fileReplacements,
359+
// Without the option, the installed version once Vite's root is known.
355360
angularVersion: options.angularVersion,
356361
emitClassMetadata: options.emitClassMetadata ?? true,
357362
compilationMode: options.compilationMode ?? 'full',
@@ -363,6 +368,17 @@ export function angular(options: PluginOptions = {}): Plugin[] {
363368
tsconfig: options.tsconfig,
364369
}
365370

371+
// The `angularVersion` option, else the `@angular/core` version installed
372+
// for the Vite root `root` (looked up once per root).
373+
let installed: { root: string; version: AngularVersion | undefined } | undefined
374+
function angularVersionFor(root: string): AngularVersion | undefined {
375+
if (options.angularVersion) return options.angularVersion
376+
if (!installed || installed.root !== root) {
377+
installed = { root, version: installedAngularVersion(root) }
378+
}
379+
return installed.version
380+
}
381+
366382
let resolvedConfig: ResolvedConfig
367383
let viteServer: ViteDevServer | undefined
368384
let watchMode = false
@@ -782,6 +798,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
782798
},
783799
configResolved(config) {
784800
resolvedConfig = config
801+
pluginOptions.angularVersion = angularVersionFor(config.root ?? workspaceRoot)
785802
},
786803
outputOptions(options) {
787804
outputMinify = options.minify
@@ -1763,7 +1780,7 @@ export function angular(options: PluginOptions = {}): Plugin[] {
17631780
angularPlugin(),
17641781
dtsPlugin(),
17651782
angularLinkerPlugin({
1766-
angularVersion: pluginOptions.angularVersion,
1783+
angularVersion: angularVersionFor,
17671784
// The linker only sees node_modules files, so it follows the vendor setting
17681785
// (Angular CLI only maps third-party code when both scripts and vendor are on).
17691786
sourcemap: pluginOptions.sourceMap && pluginOptions.vendorSourceMap,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { createRequire } from 'node:module'
3+
import { dirname, join, resolve } from 'node:path'
4+
5+
import type { AngularVersion } from '#binding'
6+
7+
/**
8+
* The version of `@angular/core` installed for the project at `root` (Vite's
9+
* root), the default of the `angularVersion` option. `undefined` when it isn't found or
10+
* is a build of Angular's own repository (`0.0.0-...`): the compiler then
11+
* targets the latest version.
12+
*/
13+
export function installedAngularVersion(root: string): AngularVersion | undefined {
14+
// A relative `dirname` chain would stop at `.`.
15+
const file = findAngularCorePackageJson(resolve(root))
16+
if (!file) return undefined
17+
try {
18+
return parseAngularVersion(JSON.parse(readFileSync(file, 'utf8')).version)
19+
} catch {
20+
return undefined
21+
}
22+
}
23+
24+
/** `22.1.0-next.3` as an `AngularVersion`; build metadata (`+...`) is dropped. */
25+
export function parseAngularVersion(version: unknown): AngularVersion | undefined {
26+
if (typeof version !== 'string') return undefined
27+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/.exec(version.trim())
28+
if (!match) return undefined
29+
const [, major, minor, patch, prerelease] = match
30+
if (Number(major) === 0) return undefined
31+
return {
32+
major: Number(major),
33+
minor: Number(minor),
34+
patch: Number(patch),
35+
...(prerelease ? { prerelease } : {}),
36+
}
37+
}
38+
39+
/**
40+
* Looked up in `node_modules` rather than with `require.resolve`, which also
41+
* follows `NODE_PATH` (set by pnpm to its hoisted store), like zone.js in
42+
* `angular-async-downlevel-plugin.ts`.
43+
*/
44+
function findAngularCorePackageJson(root: string): string | undefined {
45+
if (process.versions['pnp']) {
46+
try {
47+
return createRequire(join(root, 'package.json')).resolve('@angular/core/package.json')
48+
} catch {
49+
return undefined
50+
}
51+
}
52+
for (let dir = root; ; dir = dirname(dir)) {
53+
const file = join(dir, 'node_modules/@angular/core/package.json')
54+
if (existsSync(file)) return file
55+
if (dirname(dir) === dir) return undefined
56+
}
57+
}

0 commit comments

Comments
 (0)