From 5878a532ed165650ecbe2df51c00714bd1eff551 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 27 Mar 2026 14:41:12 +0000 Subject: [PATCH 1/3] fix(vite): handle Vite query strings in linker transform filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The linker plugin's transform filter regex used a `$` anchor that prevented matching module IDs with Vite-appended query strings (e.g. `?v=df7b0864`). When Angular packages are not pre-bundled (excluded from optimizeDeps, SSR mode, etc.), Vite serves their files through the transform pipeline with these query strings, causing the linker to miss them entirely. This left `ɵɵngDeclare*` calls unlinked, triggering JIT fallback errors at runtime. Also adds `[\\/]` for cross-platform path separator support, consistent with the existing `SKIP_REGEX`. Co-Authored-By: Claude Opus 4.6 (1M context) --- napi/angular-compiler/test/linker.test.ts | 151 ++++++++++++++++++ .../vite-plugin/angular-linker-plugin.ts | 3 +- 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 napi/angular-compiler/test/linker.test.ts diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts new file mode 100644 index 000000000..0db1154b6 --- /dev/null +++ b/napi/angular-compiler/test/linker.test.ts @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, it, expect } from 'vitest' + +import { linkAngularPackageSync } from '../index.js' + +/** + * Resolve the path to an Angular 21 package file. + * The e2e app has Angular 21.2.2 installed with chunk files. + */ +function resolveAngular21File(subpath: string): string { + return resolve(__dirname, '../e2e/app/node_modules/@angular', subpath) +} + +describe('Angular linker - chunk file support', () => { + it('should link ɵɵngDeclare calls in _platform_location-chunk.mjs', () => { + const filepath = resolveAngular21File('common/fesm2022/_platform_location-chunk.mjs') + const code = readFileSync(filepath, 'utf8') + + // Verify the chunk file has unlinked declarations + expect(code).toContain('\u0275\u0275ngDeclare') + + const result = linkAngularPackageSync(code, filepath) + + expect(result.linked).toBe(true) + expect(result.code).not.toContain('\u0275\u0275ngDeclare') + }) + + it('should link ɵɵngDeclare calls in _location-chunk.mjs', () => { + const filepath = resolveAngular21File('common/fesm2022/_location-chunk.mjs') + const code = readFileSync(filepath, 'utf8') + + // Verify the chunk file has unlinked declarations + expect(code).toContain('\u0275\u0275ngDeclare') + + const result = linkAngularPackageSync(code, filepath) + + expect(result.linked).toBe(true) + expect(result.code).not.toContain('\u0275\u0275ngDeclare') + }) + + it('should link ɵɵngDeclare calls in _common_module-chunk.mjs', () => { + const filepath = resolveAngular21File('common/fesm2022/_common_module-chunk.mjs') + const code = readFileSync(filepath, 'utf8') + + // Verify the chunk file has unlinked declarations + expect(code).toContain('\u0275\u0275ngDeclare') + + const result = linkAngularPackageSync(code, filepath) + + expect(result.linked).toBe(true) + expect(result.code).not.toContain('\u0275\u0275ngDeclare') + }) + + it('should link all Angular 21 chunk files in @angular/common', () => { + const { readdirSync } = require('node:fs') + const dir = resolveAngular21File('common/fesm2022') + const files = readdirSync(dir) as string[] + const chunkFiles = files.filter( + (f: string) => f.startsWith('_') && f.endsWith('-chunk.mjs'), + ) + + expect(chunkFiles.length).toBeGreaterThan(0) + + for (const chunkFile of chunkFiles) { + const filepath = resolve(dir, chunkFile) + const code = readFileSync(filepath, 'utf8') + + if (!code.includes('\u0275\u0275ngDeclare')) { + continue // Skip chunks without declarations + } + + const result = linkAngularPackageSync(code, filepath) + + expect(result.linked, `${chunkFile} should be linked`).toBe(true) + expect( + result.code.includes('\u0275\u0275ngDeclare'), + `${chunkFile} should not contain unlinked declarations`, + ).toBe(false) + } + }) +}) + +describe('NODE_MODULES_JS_REGEX filter matching', () => { + // This is the fixed regex from angular-linker-plugin.ts + const NODE_MODULES_JS_REGEX = /node_modules[\\/].*\.[cm]?js(?:\?.*)?$/ + + it('should match standard Angular FESM files', () => { + expect( + NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.mjs'), + ).toBe(true) + }) + + it('should match Angular 21 chunk files', () => { + expect( + NODE_MODULES_JS_REGEX.test( + 'node_modules/@angular/common/fesm2022/_platform_location-chunk.mjs', + ), + ).toBe(true) + }) + + it('should match absolute paths', () => { + expect( + NODE_MODULES_JS_REGEX.test( + '/Users/dev/project/node_modules/@angular/common/fesm2022/_platform_location-chunk.mjs', + ), + ).toBe(true) + }) + + it('should match paths with Vite query strings', () => { + expect( + NODE_MODULES_JS_REGEX.test( + 'node_modules/@angular/common/fesm2022/common.mjs?v=abc123', + ), + ).toBe(true) + }) + + it('should match chunk files with Vite query strings', () => { + expect( + NODE_MODULES_JS_REGEX.test( + 'node_modules/@angular/common/fesm2022/_platform_location-chunk.mjs?v=df7b0864', + ), + ).toBe(true) + }) + + it('should match Windows-style backslash paths', () => { + expect( + NODE_MODULES_JS_REGEX.test( + 'node_modules\\@angular\\common\\fesm2022\\common.mjs', + ), + ).toBe(true) + }) + + it('should match .js and .cjs files', () => { + expect( + NODE_MODULES_JS_REGEX.test('node_modules/@ngrx/store/fesm2022/ngrx-store.js'), + ).toBe(true) + expect( + NODE_MODULES_JS_REGEX.test('node_modules/some-lib/index.cjs'), + ).toBe(true) + }) + + it('should not match non-JS files', () => { + expect( + NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.d.ts'), + ).toBe(false) + expect( + NODE_MODULES_JS_REGEX.test('src/app/app.component.ts'), + ).toBe(false) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts index 8e38315dc..551b44ee6 100644 --- a/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-linker-plugin.ts @@ -25,7 +25,8 @@ const LINKER_DECLARATION_PREFIX = '\u0275\u0275ngDeclare' const SKIP_REGEX = /[\\/]@angular[\\/](?:compiler|core)[\\/]/ // Match JS files in node_modules (Angular FESM bundles) -const NODE_MODULES_JS_REGEX = /node_modules\/.*\.[cm]?js$/ +// Allows optional query strings (?v=...) that Vite appends to module IDs +const NODE_MODULES_JS_REGEX = /node_modules[\\/].*\.[cm]?js(?:\?.*)?$/ /** * Run the OXC Rust linker on the given code. From 767746da3791bf0811dd68bd6707fb2db6b978c0 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 27 Mar 2026 15:07:08 +0000 Subject: [PATCH 2/3] test(linker): use inline fixtures instead of Angular 21 node_modules Replace tests that read from the e2e app's @angular/common installation with self-contained inline fixtures using minimal partial declarations. This removes the fragile dependency on a specific Angular version being installed. Co-Authored-By: Claude Opus 4.6 (1M context) --- napi/angular-compiler/test/linker.test.ts | 166 +++++++++++++++------- 1 file changed, 112 insertions(+), 54 deletions(-) diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts index 0db1154b6..61df3ae4b 100644 --- a/napi/angular-compiler/test/linker.test.ts +++ b/napi/angular-compiler/test/linker.test.ts @@ -1,83 +1,141 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' import { describe, it, expect } from 'vitest' import { linkAngularPackageSync } from '../index.js' /** - * Resolve the path to an Angular 21 package file. - * The e2e app has Angular 21.2.2 installed with chunk files. + * Minimal Angular partial declaration fixtures that simulate the structure + * of FESM bundle files (including Angular 21+ chunk files). + * Uses actual Unicode ɵ (U+0275) characters as they appear in real Angular packages. */ -function resolveAngular21File(subpath: string): string { - return resolve(__dirname, '../e2e/app/node_modules/@angular', subpath) +const INJECTABLE_CHUNK = ` +import * as i0 from '@angular/core'; + +class PlatformLocation { + historyGo(relativePosition) { + throw new Error('Not implemented'); + } + static \u0275fac = i0.\u0275\u0275ngDeclareFactory({ + minVersion: "12.0.0", + version: "21.0.0", + ngImport: i0, + type: PlatformLocation, + deps: [], + target: i0.\u0275\u0275FactoryTarget.Injectable + }); + static \u0275prov = i0.\u0275\u0275ngDeclareInjectable({ + minVersion: "12.0.0", + version: "21.0.0", + ngImport: i0, + type: PlatformLocation, + providedIn: "platform", + useClass: undefined + }); } -describe('Angular linker - chunk file support', () => { - it('should link ɵɵngDeclare calls in _platform_location-chunk.mjs', () => { - const filepath = resolveAngular21File('common/fesm2022/_platform_location-chunk.mjs') - const code = readFileSync(filepath, 'utf8') +export { PlatformLocation }; +` + +const NG_MODULE_CHUNK = ` +import * as i0 from '@angular/core'; + +class CommonModule { + static \u0275fac = i0.\u0275\u0275ngDeclareFactory({ + minVersion: "12.0.0", + version: "21.0.0", + ngImport: i0, + type: CommonModule, + deps: [], + target: i0.\u0275\u0275FactoryTarget.NgModule + }); + static \u0275mod = i0.\u0275\u0275ngDeclareNgModule({ + minVersion: "14.0.0", + version: "21.0.0", + ngImport: i0, + type: CommonModule, + imports: [], + exports: [] + }); + static \u0275inj = i0.\u0275\u0275ngDeclareInjector({ + minVersion: "12.0.0", + version: "21.0.0", + ngImport: i0, + type: CommonModule + }); +} + +export { CommonModule }; +` + +const PIPE_CHUNK = ` +import * as i0 from '@angular/core'; + +class AsyncPipe { + constructor(ref) { + this._ref = ref; + } + static \u0275fac = i0.\u0275\u0275ngDeclareFactory({ + minVersion: "12.0.0", + version: "21.0.0", + ngImport: i0, + type: AsyncPipe, + deps: [{ token: i0.ChangeDetectorRef }], + target: i0.\u0275\u0275FactoryTarget.Pipe + }); + static \u0275pipe = i0.\u0275\u0275ngDeclarePipe({ + minVersion: "14.0.0", + version: "21.0.0", + ngImport: i0, + type: AsyncPipe, + isStandalone: false, + name: "async", + pure: false + }); +} - // Verify the chunk file has unlinked declarations - expect(code).toContain('\u0275\u0275ngDeclare') +export { AsyncPipe }; +` - const result = linkAngularPackageSync(code, filepath) +describe('Angular linker - chunk file linking', () => { + it('should link \u0275\u0275ngDeclareFactory and \u0275\u0275ngDeclareInjectable', () => { + const result = linkAngularPackageSync( + INJECTABLE_CHUNK, + 'node_modules/@angular/common/fesm2022/_platform_location-chunk.mjs', + ) expect(result.linked).toBe(true) expect(result.code).not.toContain('\u0275\u0275ngDeclare') }) - it('should link ɵɵngDeclare calls in _location-chunk.mjs', () => { - const filepath = resolveAngular21File('common/fesm2022/_location-chunk.mjs') - const code = readFileSync(filepath, 'utf8') - - // Verify the chunk file has unlinked declarations - expect(code).toContain('\u0275\u0275ngDeclare') - - const result = linkAngularPackageSync(code, filepath) + it('should link \u0275\u0275ngDeclareNgModule and \u0275\u0275ngDeclareInjector', () => { + const result = linkAngularPackageSync( + NG_MODULE_CHUNK, + 'node_modules/@angular/common/fesm2022/_common_module-chunk.mjs', + ) expect(result.linked).toBe(true) expect(result.code).not.toContain('\u0275\u0275ngDeclare') }) - it('should link ɵɵngDeclare calls in _common_module-chunk.mjs', () => { - const filepath = resolveAngular21File('common/fesm2022/_common_module-chunk.mjs') - const code = readFileSync(filepath, 'utf8') - - // Verify the chunk file has unlinked declarations - expect(code).toContain('\u0275\u0275ngDeclare') - - const result = linkAngularPackageSync(code, filepath) + it('should link \u0275\u0275ngDeclarePipe', () => { + const result = linkAngularPackageSync( + PIPE_CHUNK, + 'node_modules/@angular/common/fesm2022/_pipes-chunk.mjs', + ) expect(result.linked).toBe(true) expect(result.code).not.toContain('\u0275\u0275ngDeclare') }) - it('should link all Angular 21 chunk files in @angular/common', () => { - const { readdirSync } = require('node:fs') - const dir = resolveAngular21File('common/fesm2022') - const files = readdirSync(dir) as string[] - const chunkFiles = files.filter( - (f: string) => f.startsWith('_') && f.endsWith('-chunk.mjs'), + it('should return linked: false for files without declarations', () => { + const code = ` + export function helper() { return 42; } + ` + const result = linkAngularPackageSync( + code, + 'node_modules/@angular/common/fesm2022/_utils-chunk.mjs', ) - expect(chunkFiles.length).toBeGreaterThan(0) - - for (const chunkFile of chunkFiles) { - const filepath = resolve(dir, chunkFile) - const code = readFileSync(filepath, 'utf8') - - if (!code.includes('\u0275\u0275ngDeclare')) { - continue // Skip chunks without declarations - } - - const result = linkAngularPackageSync(code, filepath) - - expect(result.linked, `${chunkFile} should be linked`).toBe(true) - expect( - result.code.includes('\u0275\u0275ngDeclare'), - `${chunkFile} should not contain unlinked declarations`, - ).toBe(false) - } + expect(result.linked).toBe(false) }) }) @@ -91,7 +149,7 @@ describe('NODE_MODULES_JS_REGEX filter matching', () => { ).toBe(true) }) - it('should match Angular 21 chunk files', () => { + it('should match chunk files', () => { expect( NODE_MODULES_JS_REGEX.test( 'node_modules/@angular/common/fesm2022/_platform_location-chunk.mjs', From 0585fff29a5a35ad4e03130250604add46e7df84 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 27 Mar 2026 15:39:13 +0000 Subject: [PATCH 3/3] style: format linker test file with oxfmt Co-Authored-By: Claude Opus 4.6 (1M context) --- napi/angular-compiler/test/linker.test.ts | 36 ++++++++--------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/napi/angular-compiler/test/linker.test.ts b/napi/angular-compiler/test/linker.test.ts index 61df3ae4b..11ec34525 100644 --- a/napi/angular-compiler/test/linker.test.ts +++ b/napi/angular-compiler/test/linker.test.ts @@ -144,9 +144,9 @@ describe('NODE_MODULES_JS_REGEX filter matching', () => { const NODE_MODULES_JS_REGEX = /node_modules[\\/].*\.[cm]?js(?:\?.*)?$/ it('should match standard Angular FESM files', () => { - expect( - NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.mjs'), - ).toBe(true) + expect(NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.mjs')).toBe( + true, + ) }) it('should match chunk files', () => { @@ -167,9 +167,7 @@ describe('NODE_MODULES_JS_REGEX filter matching', () => { it('should match paths with Vite query strings', () => { expect( - NODE_MODULES_JS_REGEX.test( - 'node_modules/@angular/common/fesm2022/common.mjs?v=abc123', - ), + NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.mjs?v=abc123'), ).toBe(true) }) @@ -182,28 +180,20 @@ describe('NODE_MODULES_JS_REGEX filter matching', () => { }) it('should match Windows-style backslash paths', () => { - expect( - NODE_MODULES_JS_REGEX.test( - 'node_modules\\@angular\\common\\fesm2022\\common.mjs', - ), - ).toBe(true) + expect(NODE_MODULES_JS_REGEX.test('node_modules\\@angular\\common\\fesm2022\\common.mjs')).toBe( + true, + ) }) it('should match .js and .cjs files', () => { - expect( - NODE_MODULES_JS_REGEX.test('node_modules/@ngrx/store/fesm2022/ngrx-store.js'), - ).toBe(true) - expect( - NODE_MODULES_JS_REGEX.test('node_modules/some-lib/index.cjs'), - ).toBe(true) + expect(NODE_MODULES_JS_REGEX.test('node_modules/@ngrx/store/fesm2022/ngrx-store.js')).toBe(true) + expect(NODE_MODULES_JS_REGEX.test('node_modules/some-lib/index.cjs')).toBe(true) }) it('should not match non-JS files', () => { - expect( - NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.d.ts'), - ).toBe(false) - expect( - NODE_MODULES_JS_REGEX.test('src/app/app.component.ts'), - ).toBe(false) + expect(NODE_MODULES_JS_REGEX.test('node_modules/@angular/common/fesm2022/common.d.ts')).toBe( + false, + ) + expect(NODE_MODULES_JS_REGEX.test('src/app/app.component.ts')).toBe(false) }) })