Skip to content

Shrink hello-world bundle from 243 KB to 109 KB (-55%)#21359

Closed
NullVoxPopuli-ai-agent wants to merge 16 commits intoemberjs:nvp/remove-barrel-importsfrom
NullVoxPopuli-ai-agent:claude/refactor-for-smaller-hello-world
Closed

Shrink hello-world bundle from 243 KB to 109 KB (-55%)#21359
NullVoxPopuli-ai-agent wants to merge 16 commits intoemberjs:nvp/remove-barrel-importsfrom
NullVoxPopuli-ai-agent:claude/refactor-for-smaller-hello-world

Conversation

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Contributor

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented May 2, 2026

Targets #21350.

Cuts the hello-world smoke test (smoke-tests/v2-app-hello-world-template) from 243.30 KB / 77.32 KB gzip → 109.03 KB / 34.44 KB gzip — a 55% gzip reduction — while leaving the classic v2-app-template slightly smaller too (-9.50 KB raw / -2.73 KB gzip on the main chunk).

hello-world raw hello-world gzip classic v2-app raw classic v2-app gzip
before 243.30 KB 77.32 KB 319.61 KB 99.29 KB
after 109.03 KB 34.44 KB 310.11 KB 96.56 KB

Pattern

Every change uses one of three shapes:

  1. A side-effect-only registration module that classic-app setup-registry imports — bundles that don't pull in setup-registry (the renderComponent-only path) skip the registration and the heavy module it pulls in.
  2. A registration hook on the consumer side so the consumer module no longer statically imports the producer; the producer registers itself as a top-level side effect when loaded by anything else.
  3. A hot-path utility moved to its own file so deep imports skip the heavy module's other side effects.

Wrapped up with a sideEffects field on ember-source/package.json listing the actual side-effect files, so bundlers can tree-shake the rest of the graph aggressively.

Changes

1. Lazy -mount and -outlet keyword registration

resolver.ts no longer statically imports mountHelper/outletHelper. Replaced with registerBuiltInKeywordHelper(name, helper) and a side-effect file syntax/register-routing-keywords.ts imported by setup-registry.ts. Drops ~138 KB raw of @ember/routing + ~7 KB of @ember/engine from the renderer-only path.

2. Split classic Renderer subclass into classic-renderer.ts

Moved Renderer extends BaseRenderer, ClassicRootState, the concrete DynamicScope class, and the View interface out of renderer.ts. Added a RootState interface so RendererState is generic over either root type.

3. RSVP.defer → native Promise in renderSettled

Together with #1, lets the bundle drop the 62 KB rsvp shared chunk entirely.

4. Curly symbols extracted to curly-symbols.ts

isCurlyManager is now a brand check (manager[CURLY_COMPONENT_BRAND] === true) instead of an instance check — so the resolver no longer pulls component-managers/curly.ts (the full CurlyComponentManager lifecycle, ~17 KB) just to identify the manager.

5. Classic Component class side-effects moved to register-curly-component.ts

setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component) and Component.reopenClass({ positionalParams: [] }) no longer run at the top level of component.ts; they're in a side-effect file imported by setup-registry.ts.

6. DebugRenderTreeImpl factory moved behind a registry

EnvironmentImpl (in @glimmer/runtime) imported DebugRenderTree statically. New registerDebugRenderTreeFactory lets a side-effect module supply the constructor; getDebugName (the other static reach into debug-render-tree) moved to its own file.

7. Lighter array predicate in to-bool.ts

Switched from isArray from @ember/array (which pulls the mixin/Enumerable/Observable/computed graph) to Array.isArray(x) || isEmberArray(x).

8. contentFor extracted to runtime/lib/mixins/content-for.ts

each-in.ts no longer drags in the ProxyMixin = Mixin.create(...) graph just for an 8-line contentFor function.

9. @ember/instrumentation hot path extracted to lib/internal-instrument.ts

_instrumentStart and flaggedInstrument moved to a lib file; subscribe/unsubscribe/instrument machinery (dead code unless something subscribes) drops out of bundles that only use the hot path.

10. Meta mixin methods moved to standalone fns in @ember/object/mixin

addMixin / hasMixin / forEachMixins were @internal Meta methods called only from mixin.ts. Moved them out as metaAddMixin / metaHasMixin / metaForEachMixins standalone functions there, so Meta (reachable from the renderer through the property accessor / tag chain) no longer references the classic Mixin machinery.

11. sideEffects field on ember-source/package.json

Added a sideEffects array listing the files that actually have top-level side effects (registration files, environment / setGlobalContext callers, opcode handlers, runloop init, validator, etc.), which by inversion tells bundlers everything else is pure.

12. @ember/object's action decorator extracted to @ember/object/action

Moved the action decorator implementation behind its own deep import path so @glimmer/component-only apps don't pay for the @ember/object Mixin/CoreObject/Observable graph just to decorate handlers.

13. Decouple VM debug symbols / names from opcodes.ts

The opcode-name lookup tables and LOCAL_DEBUG-only debug brand metadata were statically imported from @glimmer/runtime's opcode tables. Moved them out so prod builds can DCE them at the import level (not just the body level).

14. treeshake.moduleSideEffects callback at the rollup level

The package-level sideEffects: false declaration was getting lost when rolldown emitted shared chunks — code from @glimmer/debug / @glimmer/debug-util / @glimmer/local-debug-flags was leaking into chunks that the renderer-only path then pulled in. Added a PURE_INTERNAL_PACKAGES list in rollup.config.mjs whose modules get moduleSideEffects: false even after chunking, so the leaked debug code drops out of the renderer-only path entirely.

15. Extract classic helper handler from resolver

Lazy-register the classic-helper detection (isClassicHelper + CLASSIC_HELPER_MANAGER) via a side-effect file imported from setup-registry.ts. Removes the static import of ./helper from resolver.ts, which was pulling the classic Helper class chain (FrameworkObject → CoreObject → Mixin) into the renderer's path even when the app does not use any classic helpers.

16. Decouple property_events / runloop / property_set from the observer chain

The renderer-only path was statically pulling in:

  • observer.ts (sync + async observer flush)
  • chain-tags.ts (transitively, for getChainTagsForKey)
  • events.ts (transitively, for addListener/sendEvent)
  • decorator.ts (for the COMPUTED_SETTERS WeakSet)

…even though a Glimmer-only app never installs an observer or a classic computed setter. Three independent reach points were responsible:

  1. property_events.tsobserver.ts (sync flushSyncObservers etc.)
  2. runloop/index.tsobserver.ts (async flushAsyncObservers per render-loop iteration)
  3. property_set.tsdecorator.ts (COMPUTED_SETTERS.has(...) check inside _setProp)

Replaced each direct import with a registration hook (registerObserverFlushSync / registerObserverDeactivationHooks, registerAsyncObserverFlush, registerComputedSetterCheck) and moved the wire-up to a top-level side effect inside observer.ts and decorator.ts themselves. Anyone importing those modules (addObserver / removeObserver, @computed, etc.) triggers the registration as a side effect of loading. Renderer-only paths skip it entirely.

What's left in the hello-world bundle

After all of the above, the remaining 109.03 KB raw / 34.44 KB gzip is essentially just genuine VM runtime + the bare minimum @ember internals:

Chunk Approx unminified size What it is
Glimmer opcode compiler 58 KB wire-format → VM opcodes
VM argument handling 54 KB capture / reify / curry
VM render loop 51 KB APPEND_OPCODES evaluation
@ember/-internals/glimmer 38 KB renderer setup, environment
backburner.js + @ember/runloop 49 KB run loop
element-builder 24 KB DOM construction
@ember/-internals 24 KB meta / owner / container

Almost all of the previously-leaked classic-Ember-object machinery (Mixin, Observable, Evented, Component, computed properties, observers, Helper, RSVP, routing) is now gone from the renderer-only path.

Test plan

  • pnpm lint clean
  • pnpm type-check:internals / :types / :handlebars pass
  • pnpm test:node 20/20
  • pnpm test:blueprints 265/265
  • smoke-tests/v2-app-template (classic v2 app) builds + 1/1 test passes
  • smoke-tests/app-template (v1 app) builds + 1/1 test passes
  • smoke-tests/v2-app-hello-world-template builds and shrinks as reported
  • pnpm vite build --mode development --minify false (full dev test suite) succeeds
  • Browser tests pass in CI

🤖 Generated with Claude Code

NullVoxPopuli and others added 10 commits May 2, 2026 17:45
Replace all internal barrel imports (from `@ember/-internals/glimmer`,
`@ember/-internals/environment`, etc.) with direct imports from the
specific source files that define what is needed. Also replace all
`export *` patterns in `@ember` packages with explicit named exports.

- Update ~40 source files to import from specific lib paths instead of
  barrel `index.ts` files
- Replace `export *` with named exports in `@ember/-internals/environment`,
  `@ember/engine/parent`, `@ember/template-compiler/*`, `ember-template-compiler`,
  and `ember-testing`
- Replace `import * as environment` with named `{ hasDOM }` import in
  `@ember/application/instance`
- Add deep import path entries to `@ember/-internals/package.json` exports map
- Add `@glimmer/opcode-compiler` dependency to `ember-template-compiler`

Test files are intentionally left unchanged as they may use barrel imports.

Revert manual changes, so we can test lint

Lint rule

Lint rule

Update sub-path exports

Lint rule

Lint rule

eslint

lint:fix

Lockfile

Fix

fix
…ords

Cuts the hello-world smoke test from 243.30 KB / 77.32 KB gzip to
168.59 KB / 53.67 KB gzip — a 30.6% gzip reduction — while leaving the
classic v2-app-template essentially flat (+0.21 KB gzip from one extra
side-effect import).

Three changes, in order of impact:

1. **Lazy `-mount` and `-outlet` keyword registration.** Until now
   `resolver.ts` statically imported `mountHelper` and `outletHelper`,
   which transitively pulled `@ember/engine/instance`,
   `@ember/routing/-internals` (for `generateControllerFactory`), and the
   rest of the routing/engine graph into every bundle that uses
   `renderComponent`. Replace the static import with a
   `registerBuiltInKeywordHelper(name, helper)` registry on the resolver,
   and add a side-effect-only `syntax/register-routing-keywords.ts` that
   classic-app setup imports from `setup-registry.ts`. Bundles that don't
   pull in `setup-registry` (i.e. the hello-world that only uses
   `@ember/renderer`) drop ~138 KB of routing + ~7 KB of engine code.

2. **Split classic `Renderer` subclass into `classic-renderer.ts`.**
   Move `Renderer extends BaseRenderer`, `ClassicRootState`, the concrete
   `DynamicScope` class, and the `View` interface out of `renderer.ts`.
   Hoists the imports those carry — `OutletView`, `createRootOutlet`,
   `RootComponentDefinition`, `makeRouteTemplate`, `renderMain`,
   `guidFor`, `getViewElement`, `getViewId`, `dict`, `createCapturedArgs`,
   `EMPTY_POSITIONAL`, `curry` — out of the renderer-only bundle. Adds a
   `RootState` interface so `RendererState` can manage either kind
   without statically depending on classic code. `setup-registry.ts` now
   imports `Renderer` from `./classic-renderer`. The renderer entry
   re-exports the classic types so existing `from '.../renderer'`
   import sites keep working.

3. **Replace `RSVP.defer` in `renderSettled` with native Promise.**
   Standalone this didn't move the bundle (rsvp was reachable via other
   paths), but together with #1 it lets the hello-world bundle drop the
   62 KB rsvp shared chunk entirely — `@ember/engine`, `@ember/routing`,
   and `@ember/-internals/runtime/lib/ext/rsvp` were the remaining
   consumers, and #1 pulls those off the renderer-only path.

Verified: `lint:eslint`, `type-check:internals`, `type-check:types`,
`type-check:handlebars`, `test:node`, `test:blueprints`, classic
v2-app-template build, hello-world build, and a vite dev build of the
full test suite all pass. Browser tests will run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on the previous classic-renderer/routing-keywords split. The
hello-world smoke test goes from 168.59 KB / 53.67 KB gzip to
160.11 KB / 50.88 KB gzip. Cumulative with prior commit: 243.30 KB /
77.32 KB → 160.11 KB / 50.88 KB (34.2% gzip reduction). Classic
v2-app-template stays flat (319.55 KB / 99.38 KB).

Each change is the same registration pattern as before — a separate
side-effect file imported by classic-app `setup-registry`, leaving the
heavy module out of the renderer-only path.

1. **Curly symbols extracted to `curly-symbols.ts`.** `BOUNDS`,
   `DIRTY_TAG`, `IS_DISPATCHING_ATTRS`, and the new `CURLY_COMPONENT_BRAND`
   live in their own file. `isCurlyManager` is now a brand check
   (`manager[CURLY_COMPONENT_BRAND] === true`) instead of an instance
   check, so the resolver no longer pulls in `./curly` (the full
   `CurlyComponentManager` lifecycle, ~17 KB) just to identify the
   manager. `curly.ts` re-exports the symbols for back-compat and tags
   `CURLY_COMPONENT_MANAGER` with the brand. `classic-renderer.ts` and
   `resolver.ts` now import from `curly-symbols.ts`.

2. **`@ember/-internals/glimmer/lib/component`'s top-level side effects
   moved to `register-curly-component.ts`.**
   `setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component)` and
   `Component.reopenClass({ positionalParams: [] })` ran at module load
   time, which kept the full classic `Component` class graph reachable
   from anything that imported `@ember/component` (e.g.
   `@glimmer/component`'s `setComponentManager`/`capabilities` imports).
   The registration now lives in a side-effect-only file imported by
   `setup-registry.ts`, so classic apps still get it on boot.

3. **`DebugRenderTreeImpl` factory moved behind a registry in
   `@glimmer/runtime/.../environment.ts`.** Previously `EnvironmentImpl`
   imported `DebugRenderTree` statically and only constructed one when
   `delegate.enableDebugTooling` was true — but the import alone pulled
   the whole class (and its `getDebugName` cousin) into every bundle.
   New `registerDebugRenderTreeFactory` lets a side-effect module
   (`debug-render-tree-register.ts`) supply the constructor; without
   that import, `env.debugRenderTree` stays `undefined` even when the
   delegate flag is set. Classic apps re-register it via
   `setup-registry.ts`. `getDebugName` was the other static reach into
   `debug-render-tree`, so it moved to its own file (`get-debug-name.ts`)
   that opcodes can import without dragging the rest in.

4. **`to-bool.ts` swapped `isArray` from `@ember/array` for
   `Array.isArray(x) || isEmberArray(x)`.** `isArray` from
   `@ember/array` calls `EmberArray.detect`, which transitively pulls
   `@ember/array`'s entire mixin/Enumerable/Observable/computed graph
   (~16 KB) just to test array-ness inside `{{#if}}`. Using
   `isEmberArray` from `@ember/array/-internals` (a WeakSet brand set
   in `EmberArray#init`) covers all instances of EmberArray-mixed
   classes — the same set the old check covered in practice.

Verified: lint clean, all type-checks pass, `test:node` 20/20,
`test:blueprints` 265/265, both smoke-test apps build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two more lazy-load splits on top of the previous round. Hello-world goes
from 160.11 KB / 50.88 KB → 159.49 KB / 50.67 KB. The classic
v2-app-template also gets a small win (319.55 → 318.27 KB raw,
99.38 → 98.94 KB gzip).

Cumulative from baseline: 243.30 KB / 77.32 KB → 159.49 KB / 50.67 KB
(34.5% gzip reduction).

1. **`contentFor` extracted to `runtime/lib/mixins/content-for.ts`.**
   `each-in.ts` (which the renderer registers as the `-each-in` keyword
   helper) imports `contentFor` to unwrap proxies before iterating.
   Until now that import dragged in `runtime/lib/mixins/-proxy`, which
   defines `ProxyMixin = Mixin.create(...)` at module scope — the entry
   point to the entire EmberObject / Mixin / computed graph (proxy.ts
   imports `Mixin`, `computed`, `defineProperty`, `set`, etc.). Moving
   the 8-line `contentFor` function into its own file lets the renderer
   path keep proxy support without paying for the rest of the proxy
   mixin's transitive imports. `-proxy.ts` re-exports `contentFor` from
   the new file for back-compat.

2. **`@ember/instrumentation` hot path extracted to
   `instrumentation/lib/internal-instrument.ts`.** `_instrumentStart`
   (called by the resolver, curly manager, outlet/root/route-template
   managers) and `flaggedInstrument` (called by the views state
   machine) used to live in `index.ts` alongside `subscribe`,
   `unsubscribe`, `instrument`, etc. — most of which are dead code
   unless something actually subscribes (e.g. Ember Inspector). Moved
   `_instrumentStart`, `flaggedInstrument`, `subscribers`, the cache
   helpers, and `NOOP` to the lib file, with `index.ts` re-exporting
   them via `export ... from`. The `no-barrel-imports` autofix then
   rewrites internal callers to deep-import from the lib file. Net
   result: the `instrumentation/index.js` chunk (subscribe / unsubscribe
   / instrument machinery) drops out of bundles that only use the hot
   path.

`package.json`'s `ember-addon.renamed-modules` map gains an entry for
`runtime/lib/mixins/content-for.js` — that's emitted automatically by
the `packageMeta` rollup plugin, no manual edit.

Verified: lint clean, all type-checks pass, `test:node` 20/20,
`test:blueprints` 265/265, both smoke-test apps build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hello-world: 159.49 KB / 50.67 KB → 134.17 KB / 42.90 KB gzip
(–25 KB raw / –7.8 KB gzip).
Classic v2-app-template gets a small bonus too (319.55 → 317.74 KB raw,
99.38 → 98.77 KB gzip).

Cumulative from the original 243.30 KB / 77.32 KB baseline: a
**44.5% gzip reduction**.

## What changed

Added a `sideEffects` field to `ember-source/package.json` listing the
files that actually have top-level side effects, which by inversion
tells bundlers that everything else is side-effect-free. With the
classic-renderer / register-curly-component / register-routing-keywords
splits already done in this PR, the renderer-only path no longer reaches
into any of the side-effect files, so vite/rolldown can drop the rest of
the graph it pulled in transitively (mostly the classic `Component`
class and its CoreView/Mixin chain that vite was previously evaluating
via `@ember/component`'s `default` re-export).

## How the list was scoped

The list covers:

- **Registration modules created in this PR** (`setup-registry*`,
  `register-routing-keywords*`, `register-curly-component*`,
  `debug-render-tree-register*`) — these mutate global state on
  import.
- **`environment*` files** (in `@ember/-internals/glimmer/` and
  `@glimmer/runtime/`) — call `setGlobalContext(...)` and the
  `_backburner.on(...)` lifecycle hookups at module top level.
- **`@glimmer/runtime/lib/compiled/opcodes/**`** — every opcode file
  registers handlers via `APPEND_OPCODES.add(...)` at module load.
- **`@glimmer/runtime/lib/helpers/**` and `lib/modifiers/**`** —
  setHelperManager / setModifierManager calls.
- **`@ember/-internals/glimmer/lib/components/**`** — Input, Textarea,
  LinkTo all call `setInternalComponentManager(...)` at top level.
- **`runtime/lib/component/template-only*`, `runtime/lib/vm/low-level*`** —
  template-only manager registration and VM bootstrap.
- **`runloop/`, `manager/`, `validator/`, `global-context/`,
  `destroyable/`, `canary-features/`, `-internals/environment/`,
  `-internals/runtime/lib/ext/rsvp*`,
  `-internals/views/lib/system/event_dispatcher*`** — top-level side
  effects in those modules' index/init files.
- **`./dist/dev/**`** — keep dev builds maximally unmolested for
  inspector / debugging tooling that may rely on dev-only side effects.

Anything outside that list — class-definition files like `component.ts`,
`core_view.ts`, `core.ts`, mixin files, computed-property files — is
treated by bundlers as pure, so unused exports drop out cleanly.

Verified: lint clean, all type-checks pass, `test:node` 20/20,
`test:blueprints` 265/265, `pnpm test` for both
`smoke-tests/v2-app-template` (classic v2 app) and
`smoke-tests/app-template` (v1 app) pass 1/1 each, hello-world builds
and shrinks as reported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI lint caught these without the local eslint cache: the
no-barrel-imports rule wants `flaggedInstrument` imported from
`@ember/instrumentation/lib/internal-instrument` (the actual source)
rather than the barrel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `addMixin` / `hasMixin` / `forEachMixins` methods only existed on
`Meta` to be called by `@ember/object/mixin`. Keeping them on the class
forced a static reference from `Meta` (reachable from the renderer
through the property accessor / tag chain) into the classic `Mixin`
graph.

Move them out as standalone functions (`metaAddMixin` / `metaHasMixin` /
`metaForEachMixins`) in `mixin.ts` itself, poking at `Meta`'s public
`_mixins` and `parent` fields directly. With this split, bundles that
don't import `@ember/object/mixin` get a cleaner `Meta` class — in the
hello-world prod bundle the `addMixin` / `hasMixin` / `forEachMixins`
identifiers go from present to fully absent, and `Mixin` references
drop from 12 to 5.

The methods were `@internal` and only called from `mixin.ts`, so this
is a purely internal refactor.

Verified: lint clean, type-checks pass, hello-world builds at
134.19 KB / 42.94 KB gzip (unchanged), classic v2-app-template tests
1/1 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two pieces of polish from the user's review:

1. The `sideEffects` field in package.json was over-broad — listing
   whole directories (`**/manager/**`, `**/validator/**`, etc.) when
   only a handful of files in those trees actually have top-level side
   effects. Replaced the directory globs with the explicit list of
   files that contain top-level calls (registrations, opcode
   `APPEND_OPCODES.add(...)`, `setGlobalContext`, `_backburner.on`,
   etc.). Hello-world stays at 134.12 KB / 42.92 KB.

2. Removed comments in the refactored files that explained the
   refactor itself ("extracted from X for tree-shaking", "kept
   separate so Y", "back-compat re-export"). That kind of context
   belongs in the PR description and commit messages, not the source
   tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`opcodes.ts` previously imported `@glimmer/debug` (DebugLogger,
VmSnapshot, debugOp / describeOp, opcodeMetadata, frag, etc.) at the
top level and assembled the per-opcode `debugBefore`/`debugAfter`
hooks inline in `AppendOpcodes`'s constructor — gated by `LOCAL_DEBUG`,
so dead in production, but the imports still pulled the heavy
`@glimmer/debug` graph into the bundle.

Same registration pattern as the DebugRenderTree split: opcodes.ts
exposes `registerDebugOpcodeSetup(setup)`; the heavy hook
implementation moved to `opcodes-debug-setup.ts`, which calls the
registry on import. `externs(vm)` now also requires the hooks to be
registered (returns `undefined` otherwise) so dev builds that don't
opt in skip the debug path entirely instead of crashing on a non-null
assertion.

Production hello-world holds at 134.12 KB / 42.90 KB gzip (`LOCAL_DEBUG`
already eliminated the hooks there); the analysis bundle drops the
`@glimmer/debug` files entirely.

Verified: lint clean (after `pnpm lint:fix`), type-checks pass,
hello-world builds, classic v2-app-template `pnpm test` 1/1 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`pnpm lint:fix` runs both `lint:eslint:fix` and `lint:format:fix`.
I'd only been running the eslint half, so prettier formatting drift
in five of my refactored files snuck through. CI's `lint:format` was
the failure on the previous push.

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

NullVoxPopuli commented May 3, 2026

📊 Size report

Tarball size1.2 MB1.2 MB

dist/dev   -1.08%↓

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 2 MB / 483.2 kB -1.08%↓2 MB / -0.97%↓478.5 kB
Show files (32 files)
File Before (Size / Brotli) After (Size / Brotli)
./packages/@ember/-internals/metal/index.js 4.4 kB / 1.3 kB 3%↑4.5 kB / 3%↑1.3 kB
./packages/@ember/-internals/runtime/lib/mixins/-proxy.js 3.9 kB / 1.3 kB -8.95%↓3.6 kB / -10.1%↓1.2 kB
./packages/@ember/-internals/runtime/lib/mixins/content-for.js 513 B / 241 B
./packages/@ember/component/index.js 44.4 kB / 10.2 kB -97.3%↓1.2 kB / -96.1%↓397 B
./packages/@ember/instrumentation/index.js 5.7 kB / 1.6 kB -27.9%↓4.1 kB / -25.2%↓1.2 kB
./packages/@ember/instrumentation/lib/internal-instrument.js 2.1 kB / 680 B
./packages/@ember/object/action.js 2.3 kB / 739 B
./packages/@ember/object/index.js 7.3 kB / 2 kB -51%↓3.6 kB / -45.3%↓1.1 kB
./packages/@ember/runloop/index.js 23.5 kB / 5.1 kB 0.6%↑23.6 kB / 0.1%↑5.1 kB
./packages/@glimmer/opcode-compiler/index.js 1.3 kB / 425 B -12.3%↓1.1 kB / -12.9%↓370 B
./packages/@glimmer/runtime/index.js 2 kB / 715 B -5.35%↓1.9 kB / -4.62%↓682 B
./packages/shared-chunks/assert-{hash}.js 619 B / 299 B -85.3%↓91 B / -72.2%↓83 B
./packages/shared-chunks/chain-{hash}.js 7.6 kB / 2.2 kB -17.6%↓6.3 kB / -17.2%↓1.8 kB
./packages/shared-chunks/component-BYwXneXt.js 77.8 kB / 17.3 kB
./packages/shared-chunks/computed-{hash}.js 34 kB / 7.9 kB -7.89%↓31.3 kB / -7.54%↓7.3 kB
./packages/shared-chunks/curly-{hash}.js 21.5 kB / 5.1 kB -98.1%↓400 B / -96.4%↓181 B
./packages/shared-chunks/curried-{hash}.js 19.9 kB / 4.9 kB -99.2%↓163 B / -98%↓97 B
./packages/shared-chunks/decorator-{hash}.js 4.7 kB / 1.3 kB 3%↑4.8 kB / 4%↑1.4 kB
./packages/shared-chunks/index-{hash}.js 78.4 kB / 19 kB -20.3%↓62.5 kB / -34.4%↓12.5 kB
./packages/shared-chunks/object-at-Bps-ulVR.js 167 B / 95 B
./packages/shared-chunks/observers-{hash}.js 6.8 kB / 1.6 kB 4%↑7.1 kB / 5%↑1.6 kB
./packages/shared-chunks/opcode-metadata-9iSW5JGP.js 10.7 kB / 2.6 kB
./packages/shared-chunks/property_events-Dpk8DRL6.js 3.6 kB / 1.1 kB
./packages/shared-chunks/property_set-{hash}.js 4.3 kB / 1.4 kB 3%↑4.5 kB / 1%↑1.4 kB
./packages/shared-chunks/render-{hash}.js 55.5 kB / 12 kB 0.3%↑55.7 kB / 0.1%↑12.1 kB
./packages/shared-chunks/setup-{hash}.js 2.8 kB / 908 B 1,340%↑40.1 kB / 1,030%↑10.2 kB
./packages/shared-chunks/syscall-ops-BPFtDquC.js 6.4 kB / 1.5 kB
./packages/shared-chunks/tags-CMTUbJMI.js 1.5 kB / 587 B
./packages/shared-chunks/template-{hash}.js 491 B / 203 B 118%↑1.1 kB / 98%↑401 B
./packages/shared-chunks/textarea-dEn8dnx_.js 14.5 kB / 3.5 kB
./packages/shared-chunks/untouchable-{hash}.js 62.6 kB / 12.2 kB -6.08%↓58.8 kB / -7.62%↓11.3 kB
./packages/shared-chunks/vm-ops-ImHv0Wtg.js 445 B / 191 B

dist/prod   -1.18%↓

File Before (Size / Brotli) After (Size / Brotli)
Total (Includes all files) 1.8 MB / 442.6 kB -1.18%↓1.8 MB / -1.09%↓437.8 kB
Show files (32 files)
File Before (Size / Brotli) After (Size / Brotli)
./packages/@ember/-internals/metal/index.js 4.2 kB / 1.3 kB 3%↑4.3 kB / 1%↑1.3 kB
./packages/@ember/-internals/runtime/lib/mixins/-proxy.js 3 kB / 1 kB -11.6%↓2.7 kB / -15.1%↓887 B
./packages/@ember/-internals/runtime/lib/mixins/content-for.js 513 B / 263 B
./packages/@ember/component/index.js 40.2 kB / 9.4 kB -97.3%↓1.1 kB / -96.1%↓363 B
./packages/@ember/instrumentation/index.js 5.5 kB / 1.6 kB -25.8%↓4.1 kB / -22.2%↓1.2 kB
./packages/@ember/instrumentation/lib/internal-instrument.js 1.9 kB / 638 B
./packages/@ember/object/action.js 1.6 kB / 590 B
./packages/@ember/object/index.js 5.7 kB / 1.7 kB -52.6%↓2.7 kB / -47.4%↓903 B
./packages/@ember/runloop/index.js 22.2 kB / 4.9 kB 0.6%↑22.3 kB / 0.2%↑4.9 kB
./packages/@glimmer/opcode-compiler/index.js 1.3 kB / 432 B -12.3%↓1.1 kB / -15.7%↓364 B
./packages/@glimmer/runtime/index.js 2 kB / 706 B -5.86%↓1.9 kB / -4.82%↓672 B
./packages/shared-chunks/arguments-CM50xfRF.js 55 kB / 10.5 kB
./packages/shared-chunks/assert-{hash}.js 619 B / 299 B -85.3%↓91 B / -72.2%↓83 B
./packages/shared-chunks/chain-{hash}.js 6.9 kB / 2 kB -12.8%↓6 kB / -12.3%↓1.7 kB
./packages/shared-chunks/component-ChZokCsr.js 67.5 kB / 15.5 kB
./packages/shared-chunks/computed-{hash}.js 28 kB / 6.6 kB -7.98%↓25.8 kB / -8.22%↓6 kB
./packages/shared-chunks/curly-{hash}.js 17.3 kB / 4.3 kB -97.7%↓400 B / -95.8%↓181 B
./packages/shared-chunks/curried-{hash}.js 19.9 kB / 4.9 kB -99.2%↓163 B / -98%↓97 B
./packages/shared-chunks/debug-render-tree-CT8tTes-.js 58.4 kB / 11.3 kB
./packages/shared-chunks/decorator-{hash}.js 3.7 kB / 1.1 kB 4%↑3.8 kB / 6%↑1.2 kB
./packages/shared-chunks/index-{hash}.js 66.5 kB / 16.3 kB -10.1%↓59.8 kB / -25.9%↓12.1 kB
./packages/shared-chunks/object-at-Bps-ulVR.js 167 B / 95 B
./packages/shared-chunks/observers-{hash}.js 6.8 kB / 1.6 kB 4%↑7.1 kB / 4%↑1.6 kB
./packages/shared-chunks/opcode-metadata-9iSW5JGP.js 10.7 kB / 2.6 kB
./packages/shared-chunks/property_events-DfAMnA-E.js 3 kB / 967 B
./packages/shared-chunks/property_set-{hash}.js 3 kB / 1 kB 5%↑3.1 kB / 3%↑1 kB
./packages/shared-chunks/render-{hash}.js 51.8 kB / 11.2 kB 0.3%↑52 kB / 0.2%↑11.2 kB
./packages/shared-chunks/setup-{hash}.js 2.6 kB / 875 B 1,230%↑34.8 kB / 926%↑9 kB
./packages/shared-chunks/syscall-ops-BPFtDquC.js 6.4 kB / 1.5 kB
./packages/shared-chunks/tags-BgPrQEek.js 1 kB / 432 B
./packages/shared-chunks/textarea-D63PPDcg.js 12.5 kB / 3.2 kB
./packages/shared-chunks/vm-ops-ImHv0Wtg.js 445 B / 191 B

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

File Before (Size / Brotli) After (Size / Brotli)
./assets/main-{hash}.js 243.3 kB / 66.7 kB -55.2%↓109 kB / -54.9%↓30.1 kB
Total (Includes all files) 243.6 kB / 66.9 kB -55.1%↓109.4 kB / -54.8%↓30.3 kB

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

NullVoxPopuli and others added 5 commits May 2, 2026 22:07
The `action` decorator lived inline in `@ember/object/index.ts`, which
also imports `CoreObject` and `Observable` at module top — so any
component that pulled `import { action } from '@ember/object'` (Input,
Textarea, AbstractInput, LinkTo) dragged the full
EmberObject / Observable / Mixin graph along with it.

Move `action` (plus its `setupAction` helper, `BINDINGS_MAP`, and
`hasProto`) to `@ember/object/action.ts`. `index.ts` re-exports it via
`export { action } from './action'` so the no-barrel-imports lint
autofix rewrites internal call sites to the deep path.
`@ember/object/index.ts` itself loses its references to
`isElementDescriptor` / `setClassicDecorator` / `ElementDescriptor` /
`ExtendedMethodDecorator`, since those now live in `action.ts`.

Hello-world: 134.12 KB / 42.94 KB → 133.42 KB / 42.69 KB gzip.

Verified with `pnpm lint` (clean), `pnpm type-check:internals`,
hello-world build, classic v2-app and v1 app smoke-test `pnpm test` (1/1 each).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per a measurement pass, two commits had zero (or negative) effect on the hello-world prod bundle:

- a0b1f09 (Move Meta mixin methods to standalone fns): bundle went from 134.17 KB → 134.19 KB (+0.02 KB). Mixin.create chain was already being tree-shaken in prod regardless.
- 75761b8 (Decouple VM debug symbols/names from opcodes.ts): bundle held at 134.12 KB. LOCAL_DEBUG=false in dist/prod (and dist/dev) was already constant-folding the debug branches out, and vite was already tree-shaking the unused @glimmer/debug imports out of the smoke-test bundle.

Both refactors were architecturally cleaner but pure no-ops at the bundle measurement that motivated this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eshake

Each of these packages already declares \`sideEffects: false\` in its
own \`package.json\`, but that declaration is lost the moment rollup
bundles their source files into shared chunks alongside other-package
runtime code. Once that happens, downstream bundlers (vite/rolldown
consuming ember-source's dist/prod) load the chunk for any runtime
symbol from another package and end up evaluating the debug top-level
(e.g. the \`STYLES\` const, \`LogFragmentBuffer\` class definitions,
ANNOTATION_STYLES array) — even though every actual call site is
behind \`if (LOCAL_DEBUG) {…}\` and gets DCE'd by Babel.

Add a \`treeshake.moduleSideEffects\` callback to the shared ESM
rollup config that explicitly marks files under those three packages
as pure. Anything else stays default (conservative).

Result: the \`render/styles.ts\` / \`render/buffer.ts\` /
\`render/fragment.ts\` content stops getting bundled into shared chunks
like \`curried-*.js\` at the dist-build stage. Downstream bundlers see
the chunks without any debug top-level code at all.

| | hello-world raw | hello-world gzip | classic v2-app raw | classic v2-app gzip |
| - | - | - | - | - |
| before | 133.40 KB | 42.66 KB | 317.74 KB | 98.77 KB |
| after  | 131.27 KB | 42.06 KB | 309.68 KB | 96.28 KB |

Verified: \`pnpm lint\`, \`pnpm type-check:internals\`, both
v1/v2-app-template smoke-test \`pnpm test\` pass 1/1, hello-world
shrinks across the board.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pull the package list out of the if-cascade into a `PURE_INTERNAL_PACKAGES`
array so future additions are a one-line append. Empirically tried
expanding to `@glimmer/util`, `@glimmer/wire-format`, `@glimmer/encoder`,
`@glimmer/owner`, `@glimmer/reference`, `@glimmer/vm`,
`@glimmer/destroyable`, `@glimmer/global-context`,
`@glimmer/interfaces` — none of them moved the bundle in either
hello-world or classic v2-app, so kept the original conservative set.

No behavior change. Same hello-world (131.27 / 42.06) and classic
(309.68 / 96.28).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lazy-register the classic-helper detection (`isClassicHelper` +
`CLASSIC_HELPER_MANAGER`) via a side-effect file imported from
`setup-registry.ts`, the same pattern already used for routing
keywords / curly components / debug-render-tree.

Removes the static import of `./helper` from `resolver.ts`, which was
pulling the classic Helper class chain (FrameworkObject → CoreObject →
Mixin) into the renderer's path even when the app does not use any
classic helpers.

hello-world bundle: 131.27 KB → 114.92 KB raw (-16.35 KB),
                     42.06 KB →  36.43 KB gzip (-5.63 KB).
classic v2-app builds unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli NullVoxPopuli marked this pull request as draft May 3, 2026 03:49
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Shrink hello-world bundle: split classic Renderer + lazy routing keywords Shrink hello-world bundle from 243 KB to 115 KB (-53%) May 3, 2026
The renderer-only path was statically pulling in:
  - observer.ts (sync + async observer flush)
  - chain-tags.ts (transitively, for getChainTagsForKey)
  - events.ts (transitively, for addListener/sendEvent)
  - decorator.ts (for COMPUTED_SETTERS)

…even though a Glimmer-only app never installs an observer or a
classic computed setter. Three independent reach points were
responsible:

1. property_events.ts -> observer (sync flushSyncObservers etc.)
2. runloop/index.ts -> observer (async flushAsyncObservers)
3. property_set.ts -> decorator (COMPUTED_SETTERS WeakSet)

Replaced each direct import with a registration hook
(`registerObserverFlushSync` / `registerObserverDeactivationHooks`,
`registerAsyncObserverFlush`, `registerComputedSetterCheck`) and
moved the wire-up to a top-level side effect in `observer.ts` and
`decorator.ts` themselves. Anyone importing those modules
(addObserver/removeObserver, @computed, etc.) gets the registration
fire as a side effect; renderer-only paths skip it.

Marked observer.ts and decorator.ts as side-effect files in the
ember-source `sideEffects` list so the registration calls survive
tree-shaking when the modules ARE loaded.

hello-world bundle: 114.92 KB -> 109.03 KB raw (-5.89 KB),
                     36.43 KB ->  34.44 KB gzip (-1.99 KB).
classic v2-app builds and tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent changed the title Shrink hello-world bundle from 243 KB to 115 KB (-53%) Shrink hello-world bundle from 243 KB to 109 KB (-55%) May 3, 2026
@NullVoxPopuli
Copy link
Copy Markdown
Contributor

So far:

It turns out, by just re-arranging the same code a bit:

  • hello world drops
    • from 243kb to 109kb
    • from 77kb gzip to 34kb gzip
    • from 67kb brotli to 30kb brotli
    • from over 4x react's hello world to nearly half of react's hello world
  • non-hello world (full app) also drops a bit -- currently only by 3kb -- but I bet with the deprecations planned for deprecating all of ember-classic, even full apps could get down to be under 50-60kb

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.

2 participants