From cc81c0d49ae791a0e7bab6c85d22dbbb710c86f2 Mon Sep 17 00:00:00 2001 From: Edouard Bozon Date: Wed, 21 Oct 2026 11:57:31 +0200 Subject: [PATCH 1/5] fix(vite): respect package `sideEffects` when marking top-level calls pure The build optimizer annotated top-level calls `/* @__PURE__ */` in every `fesm20*` file, so production builds tree-shook side-effect-only calls: zone.js bundled to nothing and `@angular/compiler` lost `publishFacade`. Like the Angular CLI, only mark top-level calls pure in `@angular/*` packages whose nearest `package.json` declares the file side-effect-free. `sideEffects` arrays are matched as Vite matches them. Metadata elision, static member wrapping and enum adjustment are unchanged. Fixes #595 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/build-optimizer.test.ts | 116 ++++++++++++++++++ .../angular-build-optimizer-plugin.ts | 55 ++++++++- 2 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 napi/angular-compiler/test/build-optimizer.test.ts diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts new file mode 100644 index 000000000..bf3867a80 --- /dev/null +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -0,0 +1,116 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { buildOptimizerPlugin } from '../vite-plugin/angular-build-optimizer-plugin.js' + +let root: string + +function writePackage(name: string, sideEffects: unknown, files: Record) { + const dir = join(root, 'node_modules', name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, sideEffects })) + for (const [file, code] of Object.entries(files)) { + mkdirSync(dirname(join(dir, file)), { recursive: true }) + writeFileSync(join(dir, file), code) + } +} + +async function optimize(file: string, code: string) { + const plugin = buildOptimizerPlugin({ jit: false, sourcemap: false, thirdPartySourcemaps: false }) + ;(plugin as any).config({ mode: 'production' }) + const result = await (plugin as any).transform.handler.call( + {}, + code, + join(root, 'node_modules', file), + ) + return result.code as string +} + +const ZONE = `var Zone2 = loadZone(); +patchCommon(Zone2); +` +const PUBLISH_FACADE = `publishFacade(_global); +` +const TOKEN = `const TOKEN = new InjectionToken('TOKEN'); +const VERSION = new Version('22.0.0'); +` +const STATIC_MEMBERS = `let Service = class Service {}; +Service.ɵfac = function Service_Factory(t) { return new (t || Service)(); }; +export { Service }; +` + +beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'build-optimizer-test-'))) + writePackage('zone.js', undefined, { 'fesm2015/zone.js': ZONE }) + writePackage('@angular/core', false, { + 'fesm2022/core.mjs': TOKEN, + 'fesm2022/service.mjs': STATIC_MEMBERS, + }) + writePackage('@angular/compiler', ['./fesm2022/compiler.mjs'], { + 'fesm2022/compiler.mjs': PUBLISH_FACADE, + 'fesm2022/other.mjs': TOKEN, + }) + writePackage('@angular/localize', ['*.mjs'], { 'fesm2022/localize.mjs': PUBLISH_FACADE }) + writePackage('@angular/animations', [], { 'fesm2022/animations.mjs': TOKEN }) + writePackage('@ngrx/store', false, { 'fesm2022/ngrx-store.mjs': TOKEN }) + writePackage('side-effectful', true, { 'fesm2022/side-effectful.mjs': STATIC_MEMBERS }) +}) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('build optimizer: package sideEffects', () => { + it('keeps top-level calls of a package without a sideEffects field', async () => { + const code = await optimize('zone.js/fesm2015/zone.js', ZONE) + + expect(code).not.toContain('__PURE__') + expect(code).toContain('patchCommon(Zone2)') + }) + + it('marks top-level calls pure in a side-effect-free @angular package', async () => { + const code = await optimize('@angular/core/fesm2022/core.mjs', TOKEN) + + expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + expect(code).toContain("/* @__PURE__ */ new Version('22.0.0')") + }) + + it('keeps top-level calls of a file listed in the sideEffects array', async () => { + const code = await optimize('@angular/compiler/fesm2022/compiler.mjs', PUBLISH_FACADE) + + expect(code).not.toContain('__PURE__') + }) + + it('marks top-level calls pure in a file not listed in the sideEffects array', async () => { + const code = await optimize('@angular/compiler/fesm2022/other.mjs', TOKEN) + + expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + }) + + it('matches a sideEffects pattern without a slash in any directory', async () => { + const code = await optimize('@angular/localize/fesm2022/localize.mjs', PUBLISH_FACADE) + + expect(code).not.toContain('__PURE__') + }) + + it('treats an empty sideEffects array as side-effect-free', async () => { + const code = await optimize('@angular/animations/fesm2022/animations.mjs', TOKEN) + + expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + }) + + it('only marks top-level calls pure in @angular packages, like the Angular CLI', async () => { + const code = await optimize('@ngrx/store/fesm2022/ngrx-store.mjs', TOKEN) + + expect(code).not.toContain('__PURE__') + }) + + it('still wraps static members of packages with side effects', async () => { + const code = await optimize('side-effectful/fesm2022/side-effectful.mjs', STATIC_MEMBERS) + + expect(code).toMatch(/let Service = \/\*\s*[@#]__PURE__\s*\*\/ \(\(\) => \{/) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts index 93fd45181..e60760b8d 100644 --- a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts @@ -1,5 +1,51 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + import { optimizeAngularPackage } from '#binding' -import type { Plugin } from 'vite' +import { createFilter, type Plugin } from 'vite' + +type SideEffectsFilter = (id: string) => boolean + +/** + * Resolves a file's `sideEffects` from its nearest `package.json`, as Vite's resolver does. + */ +function createSideEffectsResolver(): (id: string) => boolean { + const cache = new Map() + + function filterForDir(dir: string): SideEffectsFilter { + let filter = cache.get(dir) + if (filter) { + return filter + } + const pkgPath = join(dir, 'package.json') + if (existsSync(pkgPath)) { + const { sideEffects } = JSON.parse(readFileSync(pkgPath, 'utf-8')) + if (typeof sideEffects === 'boolean') { + filter = () => sideEffects + } else if (Array.isArray(sideEffects)) { + filter = + sideEffects.length === 0 + ? () => false + : createFilter( + sideEffects.map((pattern: string) => + pattern.includes('/') ? pattern : `**/${pattern}`, + ), + null, + { resolve: dir }, + ) + } else { + filter = () => true + } + } else { + const parent = dirname(dir) + filter = parent === dir ? () => true : filterForDir(parent) + } + cache.set(dir, filter) + return filter + } + + return (id) => filterForDir(dirname(id))(id) +} /** * Build optimizer plugin for Angular production builds. @@ -11,7 +57,7 @@ import type { Plugin } from 'vite' * The optimizer enables better tree-shaking by: * - Removing Angular metadata calls (ɵsetClassMetadata, etc.) * - Wrapping static fields in pure IIFEs - * - Adding /* @__PURE__ *\/ annotations to top-level calls + * - Adding /* @__PURE__ *\/ annotations to top-level calls of side-effect-free `@angular/*` packages * - Optimizing TypeScript enum patterns */ export function buildOptimizerPlugin({ @@ -24,6 +70,7 @@ export function buildOptimizerPlugin({ thirdPartySourcemaps: boolean }): Plugin { let isProd = false + const hasSideEffects = createSideEffectsResolver() return { name: '@oxc-angular/vite-optimizer', @@ -77,11 +124,13 @@ export function buildOptimizerPlugin({ } try { + // Like the Angular CLI, only trust side-effect-free `@angular/*` packages with top-level pure annotations. + const markPure = /[\\/]node_modules[\\/]@angular[\\/]/.test(id) && !hasSideEffects(id) const result = await optimizeAngularPackage(code, id, { sourcemap: sourcemap && thirdPartySourcemaps, elideMetadata: true, wrapStaticMembers: true, - markPure: true, + markPure, adjustEnums: true, }) From 595b0577d6d5eb76929c4f4e2778b8fb169fd412 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 11:30:49 +0800 Subject: [PATCH 2/5] fix(vite): read package sideEffects safely in the build optimizer A package.json that fails to parse (or starts with a BOM) used to throw inside the transform handler, which skipped every optimizer pass for the file and was never cached. Now the read is wrapped, a leading BOM is stripped, non-string sideEffects entries are ignored, and an unreadable package.json counts as having side effects, so only markPure is turned off and the result is cached. Also fix the resolver doc: the nearest package.json lookup follows esbuild / the Angular CLI, only the pattern matching follows Vite. Tests: - add an @angular package without a sideEffects field (the zone.js case never reached that branch because zone.js is not under @angular/) - add BOM, invalid JSON and mixed-array package.json cases - reuse one plugin across packages to cover the per-plugin cache - pass forward-slash ids like Vite does, read fixture code from disk, drop the unused service.mjs fixture and realpathSync Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/build-optimizer.test.ts | 101 ++++++++++++++---- .../angular-build-optimizer-plugin.ts | 18 +++- 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts index bf3867a80..33a00bbc3 100644 --- a/napi/angular-compiler/test/build-optimizer.test.ts +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -1,31 +1,39 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { normalizePath } from 'vite' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { buildOptimizerPlugin } from '../vite-plugin/angular-build-optimizer-plugin.js' let root: string -function writePackage(name: string, sideEffects: unknown, files: Record) { +function writePackage( + name: string, + sideEffects: unknown, + files: Record, + packageJson = JSON.stringify({ name, sideEffects }), +) { const dir = join(root, 'node_modules', name) mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, sideEffects })) + writeFileSync(join(dir, 'package.json'), packageJson) for (const [file, code] of Object.entries(files)) { mkdirSync(dirname(join(dir, file)), { recursive: true }) writeFileSync(join(dir, file), code) } } -async function optimize(file: string, code: string) { +function createPlugin() { const plugin = buildOptimizerPlugin({ jit: false, sourcemap: false, thirdPartySourcemaps: false }) ;(plugin as any).config({ mode: 'production' }) - const result = await (plugin as any).transform.handler.call( - {}, - code, - join(root, 'node_modules', file), - ) + return plugin +} + +async function optimize(file: string, plugin = createPlugin()) { + // Vite passes forward-slash ids to transform hooks, also on Windows. + const id = normalizePath(join(root, 'node_modules', file)) + const result = await (plugin as any).transform.handler.call({}, readFileSync(id, 'utf-8'), id) return result.code as string } @@ -43,20 +51,31 @@ export { Service }; ` beforeAll(() => { - root = realpathSync(mkdtempSync(join(tmpdir(), 'build-optimizer-test-'))) + root = mkdtempSync(join(tmpdir(), 'build-optimizer-test-')) writePackage('zone.js', undefined, { 'fesm2015/zone.js': ZONE }) - writePackage('@angular/core', false, { - 'fesm2022/core.mjs': TOKEN, - 'fesm2022/service.mjs': STATIC_MEMBERS, - }) + writePackage('@angular/core', false, { 'fesm2022/core.mjs': TOKEN }) + writePackage('@angular/build', undefined, { 'fesm2022/build.mjs': TOKEN }) writePackage('@angular/compiler', ['./fesm2022/compiler.mjs'], { 'fesm2022/compiler.mjs': PUBLISH_FACADE, 'fesm2022/other.mjs': TOKEN, }) writePackage('@angular/localize', ['*.mjs'], { 'fesm2022/localize.mjs': PUBLISH_FACADE }) writePackage('@angular/animations', [], { 'fesm2022/animations.mjs': TOKEN }) + writePackage('@angular/mixed', [false, './fesm2022/kept.mjs'], { 'fesm2022/mixed.mjs': TOKEN }) writePackage('@ngrx/store', false, { 'fesm2022/ngrx-store.mjs': TOKEN }) writePackage('side-effectful', true, { 'fesm2022/side-effectful.mjs': STATIC_MEMBERS }) + writePackage( + '@angular/bom', + false, + { 'fesm2022/bom.mjs': TOKEN }, + `\uFEFF${JSON.stringify({ sideEffects: false })}`, + ) + writePackage( + '@angular/broken', + false, + { 'fesm2022/broken.mjs': TOKEN + STATIC_MEMBERS }, + '{ "sideEffects": false, }', + ) }) afterAll(() => { @@ -64,53 +83,89 @@ afterAll(() => { }) describe('build optimizer: package sideEffects', () => { - it('keeps top-level calls of a package without a sideEffects field', async () => { - const code = await optimize('zone.js/fesm2015/zone.js', ZONE) + it('keeps top-level calls of zone.js, which is not an @angular package', async () => { + const code = await optimize('zone.js/fesm2015/zone.js') expect(code).not.toContain('__PURE__') expect(code).toContain('patchCommon(Zone2)') }) it('marks top-level calls pure in a side-effect-free @angular package', async () => { - const code = await optimize('@angular/core/fesm2022/core.mjs', TOKEN) + const code = await optimize('@angular/core/fesm2022/core.mjs') expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") expect(code).toContain("/* @__PURE__ */ new Version('22.0.0')") }) it('keeps top-level calls of a file listed in the sideEffects array', async () => { - const code = await optimize('@angular/compiler/fesm2022/compiler.mjs', PUBLISH_FACADE) + const code = await optimize('@angular/compiler/fesm2022/compiler.mjs') expect(code).not.toContain('__PURE__') }) it('marks top-level calls pure in a file not listed in the sideEffects array', async () => { - const code = await optimize('@angular/compiler/fesm2022/other.mjs', TOKEN) + const code = await optimize('@angular/compiler/fesm2022/other.mjs') expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") }) it('matches a sideEffects pattern without a slash in any directory', async () => { - const code = await optimize('@angular/localize/fesm2022/localize.mjs', PUBLISH_FACADE) + const code = await optimize('@angular/localize/fesm2022/localize.mjs') expect(code).not.toContain('__PURE__') }) it('treats an empty sideEffects array as side-effect-free', async () => { - const code = await optimize('@angular/animations/fesm2022/animations.mjs', TOKEN) + const code = await optimize('@angular/animations/fesm2022/animations.mjs') expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") }) it('only marks top-level calls pure in @angular packages, like the Angular CLI', async () => { - const code = await optimize('@ngrx/store/fesm2022/ngrx-store.mjs', TOKEN) + const code = await optimize('@ngrx/store/fesm2022/ngrx-store.mjs') expect(code).not.toContain('__PURE__') }) it('still wraps static members of packages with side effects', async () => { - const code = await optimize('side-effectful/fesm2022/side-effectful.mjs', STATIC_MEMBERS) + const code = await optimize('side-effectful/fesm2022/side-effectful.mjs') expect(code).toMatch(/let Service = \/\*\s*[@#]__PURE__\s*\*\/ \(\(\) => \{/) }) + + it('keeps top-level calls of an @angular package without a sideEffects field', async () => { + const code = await optimize('@angular/build/fesm2022/build.mjs') + + expect(code).not.toContain('__PURE__') + }) + + it('ignores sideEffects array entries that are not strings', async () => { + const code = await optimize('@angular/mixed/fesm2022/mixed.mjs') + + expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + }) + + it('reads a package.json that starts with a BOM', async () => { + const code = await optimize('@angular/bom/fesm2022/bom.mjs') + + expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + }) + + it('treats an invalid package.json as having side effects and still runs the other passes', async () => { + const code = await optimize('@angular/broken/fesm2022/broken.mjs') + + expect(code).not.toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + expect(code).toMatch(/let Service = \/\*\s*[@#]__PURE__\s*\*\/ \(\(\) => \{/) + }) + + it('keeps each package and file separate when one plugin handles many files', async () => { + const plugin = createPlugin() + + expect(await optimize('@angular/core/fesm2022/core.mjs', plugin)).toContain('__PURE__') + expect(await optimize('@angular/compiler/fesm2022/compiler.mjs', plugin)).not.toContain( + '__PURE__', + ) + expect(await optimize('@angular/compiler/fesm2022/other.mjs', plugin)).toContain('__PURE__') + expect(await optimize('@angular/build/fesm2022/build.mjs', plugin)).not.toContain('__PURE__') + }) }) diff --git a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts index e60760b8d..7e41fc67a 100644 --- a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts @@ -7,9 +7,10 @@ import { createFilter, type Plugin } from 'vite' type SideEffectsFilter = (id: string) => boolean /** - * Resolves a file's `sideEffects` from its nearest `package.json`, as Vite's resolver does. + * Reads `sideEffects` from a file's nearest `package.json` (as esbuild does for the Angular CLI) + * and matches it the way Vite's resolver does. */ -function createSideEffectsResolver(): (id: string) => boolean { +function createSideEffectsResolver(): SideEffectsFilter { const cache = new Map() function filterForDir(dir: string): SideEffectsFilter { @@ -19,15 +20,22 @@ function createSideEffectsResolver(): (id: string) => boolean { } const pkgPath = join(dir, 'package.json') if (existsSync(pkgPath)) { - const { sideEffects } = JSON.parse(readFileSync(pkgPath, 'utf-8')) + let sideEffects: unknown + try { + const raw = readFileSync(pkgPath, 'utf-8') + sideEffects = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw).sideEffects + } catch { + // A package.json we cannot read is treated as having side effects. + } if (typeof sideEffects === 'boolean') { filter = () => sideEffects } else if (Array.isArray(sideEffects)) { + const patterns = sideEffects.filter((pattern) => typeof pattern === 'string') filter = - sideEffects.length === 0 + patterns.length === 0 ? () => false : createFilter( - sideEffects.map((pattern: string) => + patterns.map((pattern: string) => pattern.includes('/') ? pattern : `**/${pattern}`, ), null, From c54506f7bfa20572cf6f43a15e493a0a6abe2b72 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 11:40:22 +0800 Subject: [PATCH 3/5] test(vite): check that string sideEffects entries still count next to bad ones The mixed sideEffects test only optimized the unlisted file, so a version that dropped the whole array on any non-string entry still passed. It now also optimizes the listed file and expects no pure marks there. Fix the JSDoc of the sideEffects resolver: the matching follows esbuild (the Angular CLI), not the Vite 8 build, which uses rolldown and differs for string values and for patterns like './x.mjs'. Co-Authored-By: Claude Opus 5.5 (1M context) --- napi/angular-compiler/test/build-optimizer.test.ts | 6 +++++- .../vite-plugin/angular-build-optimizer-plugin.ts | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts index 33a00bbc3..9946f3292 100644 --- a/napi/angular-compiler/test/build-optimizer.test.ts +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -61,7 +61,10 @@ beforeAll(() => { }) writePackage('@angular/localize', ['*.mjs'], { 'fesm2022/localize.mjs': PUBLISH_FACADE }) writePackage('@angular/animations', [], { 'fesm2022/animations.mjs': TOKEN }) - writePackage('@angular/mixed', [false, './fesm2022/kept.mjs'], { 'fesm2022/mixed.mjs': TOKEN }) + writePackage('@angular/mixed', [false, './fesm2022/kept.mjs'], { + 'fesm2022/mixed.mjs': TOKEN, + 'fesm2022/kept.mjs': PUBLISH_FACADE, + }) writePackage('@ngrx/store', false, { 'fesm2022/ngrx-store.mjs': TOKEN }) writePackage('side-effectful', true, { 'fesm2022/side-effectful.mjs': STATIC_MEMBERS }) writePackage( @@ -143,6 +146,7 @@ describe('build optimizer: package sideEffects', () => { const code = await optimize('@angular/mixed/fesm2022/mixed.mjs') expect(code).toContain("/* @__PURE__ */ new InjectionToken('TOKEN')") + expect(await optimize('@angular/mixed/fesm2022/kept.mjs')).not.toContain('__PURE__') }) it('reads a package.json that starts with a BOM', async () => { diff --git a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts index 7e41fc67a..7fd0fe5e7 100644 --- a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts @@ -7,8 +7,9 @@ import { createFilter, type Plugin } from 'vite' type SideEffectsFilter = (id: string) => boolean /** - * Reads `sideEffects` from a file's nearest `package.json` (as esbuild does for the Angular CLI) - * and matches it the way Vite's resolver does. + * Reads `sideEffects` from a file's nearest `package.json` and matches it like esbuild does for + * the Angular CLI: a value that is not a boolean or an array counts as having side effects, and a + * pattern with a slash only matches from the package root. Vite 8 builds (rolldown) differ on both. */ function createSideEffectsResolver(): SideEffectsFilter { const cache = new Map() From e2c66b0f930e98c0615b7a5aa34a471f2045a08e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 11:48:33 +0800 Subject: [PATCH 4/5] test(vite): cover sideEffects: true in an @angular package The boolean sideEffects branch was only tested with `false`. The only `sideEffects: true` fixture was outside @angular, so the @angular check already turned markPure off there. Add an @angular fixture with `sideEffects: true` and check that its top-level calls stay unmarked. Also reword the markPure comment so it says pure annotations are added (not already present), and wrap it and the JSDoc bullet to 100 columns. Co-Authored-By: Claude Opus 5.5 (1M context) --- napi/angular-compiler/test/build-optimizer.test.ts | 7 +++++++ .../vite-plugin/angular-build-optimizer-plugin.ts | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts index 9946f3292..42f8d9950 100644 --- a/napi/angular-compiler/test/build-optimizer.test.ts +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -55,6 +55,7 @@ beforeAll(() => { writePackage('zone.js', undefined, { 'fesm2015/zone.js': ZONE }) writePackage('@angular/core', false, { 'fesm2022/core.mjs': TOKEN }) writePackage('@angular/build', undefined, { 'fesm2022/build.mjs': TOKEN }) + writePackage('@angular/upgrade', true, { 'fesm2022/upgrade.mjs': TOKEN }) writePackage('@angular/compiler', ['./fesm2022/compiler.mjs'], { 'fesm2022/compiler.mjs': PUBLISH_FACADE, 'fesm2022/other.mjs': TOKEN, @@ -142,6 +143,12 @@ describe('build optimizer: package sideEffects', () => { expect(code).not.toContain('__PURE__') }) + it('keeps top-level calls of an @angular package with sideEffects: true', async () => { + const code = await optimize('@angular/upgrade/fesm2022/upgrade.mjs') + + expect(code).not.toContain('__PURE__') + }) + it('ignores sideEffects array entries that are not strings', async () => { const code = await optimize('@angular/mixed/fesm2022/mixed.mjs') diff --git a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts index 7fd0fe5e7..eff3cb267 100644 --- a/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts +++ b/napi/angular-compiler/vite-plugin/angular-build-optimizer-plugin.ts @@ -66,7 +66,8 @@ function createSideEffectsResolver(): SideEffectsFilter { * The optimizer enables better tree-shaking by: * - Removing Angular metadata calls (ɵsetClassMetadata, etc.) * - Wrapping static fields in pure IIFEs - * - Adding /* @__PURE__ *\/ annotations to top-level calls of side-effect-free `@angular/*` packages + * - Adding /* @__PURE__ *\/ annotations to top-level calls of side-effect-free + * `@angular/*` packages * - Optimizing TypeScript enum patterns */ export function buildOptimizerPlugin({ @@ -133,7 +134,8 @@ export function buildOptimizerPlugin({ } try { - // Like the Angular CLI, only trust side-effect-free `@angular/*` packages with top-level pure annotations. + // Like the Angular CLI, only add top-level pure annotations to `@angular/*` packages + // that declare themselves side-effect-free. const markPure = /[\\/]node_modules[\\/]@angular[\\/]/.test(id) && !hasSideEffects(id) const result = await optimizeAngularPackage(code, id, { sourcemap: sourcemap && thirdPartySourcemaps, From 0a10cd83448516a5ef9d4302a2b881e743ca6004 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 11:56:04 +0800 Subject: [PATCH 5/5] test(vite): stop passing an unused sideEffects value in raw package.json fixtures writePackage only reads its sideEffects argument to build the default package.json. The @angular/bom and @angular/broken fixtures pass a raw package.json, so their `false` argument was never used and read as if it set the field. Pass undefined there and note the rule on the helper. Co-Authored-By: Claude Opus 5.5 (1M context) --- napi/angular-compiler/test/build-optimizer.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/test/build-optimizer.test.ts b/napi/angular-compiler/test/build-optimizer.test.ts index 42f8d9950..e73396e18 100644 --- a/napi/angular-compiler/test/build-optimizer.test.ts +++ b/napi/angular-compiler/test/build-optimizer.test.ts @@ -9,6 +9,7 @@ import { buildOptimizerPlugin } from '../vite-plugin/angular-build-optimizer-plu let root: string +// `packageJson`, when given, is written as is and `sideEffects` is not used. function writePackage( name: string, sideEffects: unknown, @@ -70,13 +71,13 @@ beforeAll(() => { writePackage('side-effectful', true, { 'fesm2022/side-effectful.mjs': STATIC_MEMBERS }) writePackage( '@angular/bom', - false, + undefined, { 'fesm2022/bom.mjs': TOKEN }, `\uFEFF${JSON.stringify({ sideEffects: false })}`, ) writePackage( '@angular/broken', - false, + undefined, { 'fesm2022/broken.mjs': TOKEN + STATIC_MEMBERS }, '{ "sideEffects": false, }', )