Skip to content

Declare side-effecting modules so bundlers have better hints for what can be tree-shaken#21365

Open
NullVoxPopuli wants to merge 6 commits intomainfrom
nvp/configure-side-effects
Open

Declare side-effecting modules so bundlers have better hints for what can be tree-shaken#21365
NullVoxPopuli wants to merge 6 commits intomainfrom
nvp/configure-side-effects

Conversation

@NullVoxPopuli
Copy link
Copy Markdown
Contributor

@NullVoxPopuli NullVoxPopuli commented May 3, 2026

Important

The key thing is that the side-effects signal is only relevant if nothing is imported from a file.


Demo:


bundler hints that let consumer bundlers (vite/rolldown, webpack, esbuild) tree-shake aggressively through ember-source's internal barrel re-exports without requiring any source-file restructuring.

for the ember-source dist/tarball, the result is that chunks shake out a bit differently.

on webpack docs:

the "sideEffects" package.json property to denote which files in your project are "pure" and therefore safe to prune if unused.
https://webpack.js.org/guides/tree-shaking/

on svelte's docs
https://github.com/sveltejs/kit/pull/10691/changes#diff-4b03f147d0e7e4750663fcdb0ef3bf8ba11ad0bc47204456bf5d7aa5fd9a7e00R129

They do recommend explicitly declaring which modules have side-effects (which is something I want to do, I just want it generated)

this person says sideEffects is "standard" vitejs/vite#14321 (comment)

from rollup:

assume modules and external dependencies from which nothing is imported do not have other side effects like mutating global variables or logging without checking
https://rollupjs.org/configuration-options/#treeshake-modulesideeffects

image

I believe this means that it happens that if something is used from a file, it can have side-effects within that file -- which is good.

Because, say you never use helpers:

we want:

import '@ember/helper`

to be removed, because you never used them.
let's say that file also registers helper managers.
the output removes the import entirely.
but because you don't use helpers, there is no behavior loss.

If you did

import { element } from '@ember/helper'

then the file is used, and any side-effects within would remain, because deeper analysis occurs.

however, if we declared @ember/helper as having side-effects in pacakge.json, we'd likely not get the correct stripping behavior that we want.

so, it is "less scary" to have all side-effects actually live in private files so that this "did you import it?" check and deeper analysis trigger isn't directly controlled by users

@NullVoxPopuli NullVoxPopuli marked this pull request as ready for review May 3, 2026 14:44
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 3, 2026

📊 Size report

Tarball size1.2 MB1.2 MB

dist/dev   -1.2%↓

File Before (Size / Brotli) After (Size / Brotli)
./packages/@glimmer/opcode-compiler/index.js 1.3 kB / 426 B -12.3%↓1.1 kB / -14.8%↓363 B
./packages/@glimmer/runtime/index.js 2.2 kB / 777 B -4.92%↓2.1 kB / -3.99%↓746 B
./packages/shared-chunks/assert-{hash}.js 619 B / 299 B -85.3%↓91 B / -72.2%↓83 B
./packages/shared-chunks/curried-{hash}.js 19.9 kB / 4.9 kB -99.2%↓163 B / -98%↓97 B
./packages/shared-chunks/opcode-metadata-9iSW5JGP.js 10.7 kB / 2.6 kB
./packages/shared-chunks/syscall-ops-BPFtDquC.js 6.4 kB / 1.5 kB
./packages/shared-chunks/template-{hash}.js 491 B / 203 B 118%↑1.1 kB / 99%↑403 B
./packages/shared-chunks/vm-ops-ImHv0Wtg.js 445 B / 191 B
Total (Includes all files) 2 MB / 484.8 kB -1.2%↓2 MB / -1.27%↓478.6 kB

dist/prod   -1.31%↓

File Before (Size / Brotli) After (Size / Brotli)
./packages/@glimmer/opcode-compiler/index.js 1.3 kB / 425 B -12.3%↓1.1 kB / -14.4%↓364 B
./packages/@glimmer/runtime/index.js 2.2 kB / 767 B -5%↓2.1 kB / -3.91%↓737 B
./packages/shared-chunks/assert-{hash}.js 619 B / 299 B -85.3%↓91 B / -72.2%↓83 B
./packages/shared-chunks/curried-{hash}.js 19.9 kB / 4.9 kB -99.2%↓163 B / -98%↓97 B
./packages/shared-chunks/opcode-metadata-9iSW5JGP.js 10.7 kB / 2.6 kB
./packages/shared-chunks/syscall-ops-BPFtDquC.js 6.4 kB / 1.5 kB
./packages/shared-chunks/vm-ops-ImHv0Wtg.js 445 B / 191 B
Total (Includes all files) 1.8 MB / 444.1 kB -1.31%↓1.8 MB / -1.37%↓438 kB

smoke-tests/v2-app-hello-world-template/dist   -37.5%↓

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 243.3 kB / 66.7 kB -37.6%↓151.9 kB / -36.7%↓42.2 kB
Total (Includes all files) 243.6 kB / 66.8 kB -37.5%↓152.2 kB / -36.7%↓42.3 kB

🤖 This report was automatically generated by wyvox/pkg-size

@NullVoxPopuli NullVoxPopuli marked this pull request as draft May 3, 2026 15:04
@NullVoxPopuli NullVoxPopuli changed the title Shrink hello-world bundle from 251 KB to 173 KB (-31%) via bundler hints Declare side-effecting modules so bundlers have better hints for what can be tree-shaken May 3, 2026
@NullVoxPopuli NullVoxPopuli marked this pull request as ready for review May 3, 2026 21:04
Comment thread bin/check-tree-shake.mjs
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a manual lint, of sorts

NullVoxPopuli and others added 6 commits May 6, 2026 15:13
Two purely-additive bundler hints that let consumer bundlers
(vite/rolldown, webpack, esbuild) tree-shake aggressively through
ember-source's internal barrel re-exports without requiring any
source-file restructuring.

1. **`"sideEffects": false` on `ember-source/package.json`** -
   declares that no module in this package has top-level side
   effects that need to be preserved if the module's exports
   are unused. The bundler can then DCE re-exports through
   `index.ts` barrels that currently anchor the rest of the
   graph in place.

   This is safe in practice because rollup's chunking groups
   symbols with their side effects: any chunk containing the
   classic `Component` class also contains the
   `setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component)`
   call, the `setHelperManager` registrations live in the chunk
   that holds `helper.ts`, etc. Importing a symbol from a chunk
   pulls the chunk's side effects along; apps that don't reach
   those symbols don't need their side effects either.

2. **`treeshake.moduleSideEffects` callback in `rollup.config.mjs`** -
   the package-level `sideEffects: false` declarations on
   `@glimmer/debug`, `@glimmer/debug-util`, and
   `@glimmer/local-debug-flags` get lost when rolldown emits
   shared chunks (debug code from these packages can leak into
   chunks that the renderer-only path then pulls in). The callback
   re-asserts module purity at the chunk level so leaked debug
   code drops out of the renderer-only path.

Measured against `smoke-tests/v2-app-hello-world-template`:

| | raw | gzip |
| - | - | - |
| before | 251.05 KB | 79.75 KB |
| after  | 172.99 KB | 55.31 KB |

Classic `v2-app-template` and v1 `app-template` smoke tests still
build and pass. `pnpm test:node` 20/20. `pnpm vite build --mode
development` (full dev test suite app) builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`sideEffects: false` declares a contract that's invisible to
reviewers: any module-level mutation (`set*Manager(...)`,
`Foo.reopenClass(...)`, `_backburner = new Backburner(...)`)
silently rots the contract. Bundlers will drop these side effects
when the symbol they ride along with isn't imported.

Adds a `pnpm test:tree-shake` script that bundles each pure-today
entry point with rollup as if a consumer imported it for side
effects only, and asserts nothing survives. Regression in any
known-pure entry fails CI with an actionable message.

Today the list is 23 entries — `@glimmer/util`, `@glimmer/destroyable`,
`@ember/owner`, `@ember/version`, etc. The 43 currently-impure entries
(@ember/-internals/glimmer, @ember/runloop, @glimmer/runtime, …) are
omitted because they have known top-level side effects that
sideEffects: false promises bundlers can drop in practice anyway.

Patch: agadoo's bundled acorn pins ecmaVersion 11 (ES2020), which
chokes on private class fields and other ES2022+ syntax that appears
in dist. The patch bumps it to `'latest'`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Of the three entries previously in PURE_INTERNAL_PACKAGES, only
`@glimmer/debug` actually contributes to the hello-world bundle
size:

| array contents | hello-world raw | gzip |
| - | - | - |
| `[]` (no callback)                       | 181.12 KB | 57.82 KB |
| `['@glimmer/debug-util']`                 | 181.12 KB | 57.82 KB |
| `['@glimmer/local-debug-flags']`          | 181.12 KB | 57.82 KB |
| `['@glimmer/debug']`                      | 172.99 KB | 55.35 KB |
| all three                                 | 172.99 KB | 55.31 KB |

`@glimmer/debug-util` and `@glimmer/local-debug-flags` are
already inferred pure by rolldown's static analysis (their content
either has no top-level statements that look like side effects, or
gets fully stripped by the LOCAL_DEBUG macro in prod builds).
Re-asserting them adds nothing.

Inlined the array since the callback now checks a single path
prefix. Added a note explaining the measurement so the next
person adding to the list does so with data, not pattern-matching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli
Copy link
Copy Markdown
Contributor Author

The lint for this was extracted to: #21378

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.

1 participant