Skip to content

fix(vite): respect package sideEffects when marking top-level calls pure - #596

Merged
Brooooooklyn merged 6 commits into
voidzero-dev:mainfrom
edbzn:fix/optimizer-respect-side-effects
Oct 22, 2026
Merged

Brooooooklyn merged 6 commits into
voidzero-dev:mainfrom
edbzn:fix/optimizer-respect-side-effects

Conversation

@edbzn

@edbzn edbzn commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #595.

In production builds, @oxc-angular/vite-optimizer ran optimizeAngularPackage(..., { markPure: true }) on every fesm20* file and ignored the package's sideEffects. Top-level calls that exist only for their side effects got /* @__PURE__ */ and were tree-shaken:

  • zone.js (no sideEffects field) bundled to nothing, so a zone-based app failed with NG0908 at bootstrap.
  • @angular/compiler ("sideEffects": ["./fesm2022/compiler.mjs"]) lost publishFacade(_global), so JIT failed with JIT compiler unavailable.

What changed

The optimizer plugin now passes markPure: true only for files under node_modules/@angular/ whose nearest package.json marks them side-effect-free. Everything else gets markPure: false. Metadata elision, static member wrapping and enum adjustment run as before.

sideEffects is read from the nearest package.json, cached per directory, and matched the way Vite's resolver matches it: a boolean as is, [] as side-effect-free, and array patterns through createFilter relative to the package directory, with **/ prepended to patterns that have no /. this.getModuleInfo(id).moduleSideEffects can't be used here: in a Vite 8.3.4 build it is null in the transform hook for zone.js, @angular/core and @angular/compiler.

How it matches the Angular CLI

In @angular/build (22.2), javascript-transformer-worker.js computes safeAngularPackage = sideEffects === false && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename), and only safe packages get top-level pure annotations (topLevelSafeMode = !safeAngularPackage). sideEffects comes from esbuild's build.resolve(), which applies the package.json field. Metadata elision, static member wrapping and enum adjustment run for every file, so they stay ungated here too.

Two differences remain:

  • Non-Angular packages that declare "sideEffects": false (e.g. @ngrx/store) no longer get top-level pure annotations, as in the Angular CLI. Those modules are still dropped whole when unused.
  • In safe mode, the Angular CLI still annotates new on a short list of known side-effect-free constructors. This PR doesn't port that.

Verification

  • New test/build-optimizer.test.ts drives the plugin on fixture packages written to a temp dir. Cases: a package without sideEffects, sideEffects: false, an array that matches the file, an array that doesn't, a pattern with no /, [], a side-effect-free non-@angular package, and static member wrapping in a side-effectful package. 4 of the 8 tests fail on main; all pass with the fix.
  • pnpm test (455 passed), pnpm check, tsc -p tsconfig.json.
  • Real vite build (Vite 8.3.4, Angular 22.2.0, zone.js 0.16.0) of an app that imports zone.js and @angular/compiler and uses provideZoneChangeDetection():
    • With @oxc-angular/vite@0.0.42, the bundle has no loadZone()/patchCommon()/publishFacade() calls, and the app throws NG0908 at bootstrap.
    • With this branch, all of those calls are in the bundle, a setTimeout in the component runs in the angular zone and updates the view, and ng.ɵcompilerFacade is set.
  • A zoneless app (@angular/core + @angular/platform-browser only) builds to a byte-identical bundle with 0.0.42 and with this branch.

Maintainer follow-up

Merged main (#598) into the branch, then polished after a review pass:

  • Reading package.json can no longer turn off the whole optimizer for a file. A BOM is stripped, non-string sideEffects entries are ignored, and an unreadable file counts as having side effects. Metadata elision, static wrapping and enum adjustment still run.
  • The doc comment now says what the lookup follows: nearest package.json and esbuild-style matching, like the Angular CLI. Vite 8 (rolldown) reads only the package root, but real Angular 22 packages give the same answer either way (68 resolved @angular ids checked, 0 differences).
  • Tests use forward-slash ids like Vite (Windows CI) and read fixtures from disk. They now also cover an @angular package with no sideEffects field (the actual cause of optimizer: markPure ignores package sideEffects, tree-shakes zone.js and @angular/compiler in production builds #595), sideEffects: true, a BOM, broken JSON, and mixed arrays.

Open questions, not changed here:

  • AOT apps that call platformBrowserDynamic() now keep @angular/compiler (about 240 KB → 698 KB minified), because its publishFacade is no longer dropped. That is correct for optimizer: markPure ignores package sideEffects, tree-shakes zone.js and @angular/compiler in production builds #595. The Angular CLI avoids it by rewriting the AOT bootstrap to platformBrowser(), which this plugin doesn't do. Follow-up?
  • In safe mode the CLI still marks new InjectionToken(...) pure (sideEffectFreeConstructors). Not ported, as noted above.
  • Lookup rule: keep nearest package.json (CLI/esbuild), or read the package root like Vite 8?

🤖 Generated with Claude Code

edbzn and others added 6 commits October 21, 2026 11:57
… 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 voidzero-dev#595

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…son 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) <noreply@anthropic.com>
@Brooooooklyn

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-22T04:05:36.381195Z 0a10cd8 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 0a10cd8344

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Brooooooklyn
Brooooooklyn merged commit dbb55fb into voidzero-dev:main Oct 22, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

optimizer: markPure ignores package sideEffects, tree-shakes zone.js and @angular/compiler in production builds

2 participants