Repository navigation
Allow third-party packages declaring sideEffects: false to opt out of topLevelSafeMode #33893
Description
Command
build
Description
markTopLevelPure only runs in its aggressive mode for files under node_modules/@angular/. For every other package the safe mode is active, and in safe mode no top-level call expression is ever annotated as pure — only new expressions whose callee is in a one-entry allow-list (InjectionToken).
The consequence is that a third-party library cannot be tree-shaken past any top-level function call, even when it correctly declares sideEffects: false. And because the pure annotation has to sit at the call site, the authors of the called helper cannot fix it from their side either.
This hits a common pattern in styling libraries — class-variance-authority, tailwind-variants, and any defineX()-style helper:
// my-lib/ui/button.cva.ts — the package declares "sideEffects": false
export const buttonVariants = cva(["inline-flex items-center", /* … */]);Every such call and its string payload is retained in the consumer's bundle, whether or not the component that uses it is imported.
Relevant code on main:
packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts:211-213const safeAngularPackage = sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); const topLevelSafeMode = !safeAngularPackage;
packages/angular/build/src/tools/oxc/oxc-transform.ts:727—if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) return;packages/angular/build/src/tools/oxc/oxc-transform.ts:761-773— in safe mode onlysideEffectFreeConstructorsis annotated, and that set isnew Set(['InjectionToken']).
Worth noting that sideEffectFree already derives from the package's own sideEffects: false declaration — the field is read, and then trust is narrowed to first-party packages by the path test.
Impact, measured. An Angular component library (23 components, sideEffects: false, ng-packagr FESM, 35 top-level cva() calls), consumed through its published FESM and bundled with the production pipeline (linker → advanced optimizations → esbuild --minify):
| imported symbol | as published | with the 35 cva() calls manually annotated /*#__PURE__*/ |
|---|---|---|
| a 2-line pure string helper | 76 858 B | 1 472 B |
| a component using no variants | 77 229 B | 2 451 B |
| a simple button component | 77 452 B | 7 005 B |
| the most complex component | 86 513 B | 17 642 B |
To be precise about what that figure is: it is what a minimal import retains, i.e. the cost of variants belonging to components the consumer never imports. It is not universal waste — an application that uses most of the library needs those variants anyway.
Measured counter-example, same library: a real application importing 26 of its symbols (20 of them components) saw its production build change by 198 bytes out of 14 MB when the call sites were annotated — nothing. So the impact scales with how much of the library goes unused. That is the common case for a component library consumed selectively, and it is why the behaviour seems worth changing, but I would rather state the bound than overstate it.
Worth noting too that this only affects consumers of the published FESM. Inside a workspace, where each *.cva.ts is still its own module, sideEffects: false already lets the whole module be dropped and no annotation is needed — the problem is specific to the flattened bundle, where elimination has to happen statement by statement.
Why the library side cannot solve it. Same bundle, same function, esbuild 0.28.1:
// lib.mjs
// @__NO_SIDE_EFFECTS__
export function h(x) { return { v: x }; }
// mid.mjs
import { h } from "./lib.mjs";
export const tiny = 1;
export const b1 = h("HHHH…"); // retained
export const b2 = /*@__PURE__*/ h("KKKK…"); // eliminatedImporting only tiny keeps b1 and drops b2. All three documented forms of /*@__NO_SIDE_EFFECTS__*/ (line comment before function, block comment before function, block comment before the arrow of a const) left the call in place. Only the call-site annotation works — and the call site belongs to the consuming library, not to the helper. For reference, cva@1.0.0-beta.8 does declare sideEffects: false and still ships no annotations, so an upstream release of the helper does not address this.
I searched open and closed issues for markTopLevelPure, safeAngularPackage, pure-toplevel-functions and related tree-shaking terms before filing, and found nothing covering this.
Describe the solution you'd like
Some way for an application to opt specific third-party packages into the aggressive mode — i.e. to disable topLevelSafeMode for packages it chooses to trust. What shape that takes, and whether it belongs in the build configuration at all, is for you to judge.
The reason for suggesting an opt-in rather than a change of default: some packages declare sideEffects: false incorrectly, so enabling the aggressive mode across all of node_modules could break existing apps. An opt-in leaves that call with the team that knows its own dependencies.
Describe alternatives you've considered
- Drop the
node_modules/@angular/path test, letting the aggressive mode follow thesideEffects: falsedeclaration a package already makes. Semantically the most correct reading of the field — it is precisely a promise that importing the module has no observable side effect — but it changes the default for every dependency at once, with the breakage risk noted above. - Extend
sideEffectFreeConstructorsinto a call-expression allow-list (e.g.cva,tv). Cheap, but arbitrary and endless, and it privileges specific libraries. - Annotate every top-level call site in the library. This does work — the annotation survives
tscandng-packagrinto the published FESM. We tried it across three libraries (69 call sites) and then dropped it, because for our own main consumer the benefit measured as zero (the counter-example above); we were not willing to carry the churn and a lint rule for a gain we could not demonstrate. It is also invisible from the outside: nothing in the current behaviour hints that a library author should do this, which is why option 1 or 2 — or simply documenting it — seems more useful than leaving each library to rediscover it.
Activity
alan-agius4 commentedon Aug 21, 2026
Thanks for opening this issue and for providing the detailed measurements and context!
While we understand the friction with top-level helper calls like
cva()in FESM bundles, we don't plan to add an application-level configuration option to opt third-party packages into aggressive top-level pure marking.A few reasons behind this decision:
"sideEffects": falseinpackage.jsonis a module-level assertion (allowing a bundler to drop an entirely unused module), not a statement-level guarantee that every individual top-levelCallExpressioninside an evaluated module is side-effect free.198 bytesout of a14 MBbundle), the potential savings in typical applications are very small. Adding and maintaining a new build configuration option for this micro-optimization would add unnecessary complexity to the CLI surface area./*#__PURE__*/, or for upstream bundlers (likeesbuild) to support function-level annotations like/*@__NO_SIDE_EFFECTS__*/. Angular CLI'smarkTopLevelPureis scoped to@angular/*packages because we control those packages and can guarantee that their top-level calls are safe to eliminate.