Skip to content

Allow third-party packages declaring sideEffects: false to opt out of topLevelSafeMode #33893

Description

@hebus

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-213
    const safeAngularPackage =
      sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
    const topLevelSafeMode = !safeAngularPackage;
  • packages/angular/build/src/tools/oxc/oxc-transform.ts:727if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) return;
  • packages/angular/build/src/tools/oxc/oxc-transform.ts:761-773 — in safe mode only sideEffectFreeConstructors is annotated, and that set is new 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…");    // eliminated

Importing 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

  1. Drop the node_modules/@angular/ path test, letting the aggressive mode follow the sideEffects: false declaration 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.
  2. Extend sideEffectFreeConstructors into a call-expression allow-list (e.g. cva, tv). Cheap, but arbitrary and endless, and it privileges specific libraries.
  3. Annotate every top-level call site in the library. This does work — the annotation survives tsc and ng-packagr into 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions