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
Closed
Conversation
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>
Contributor
📊 Size reportTarball size — dist/dev -1.08%↓
Show files (32 files)
dist/prod -1.18%↓
Show files (32 files)
smoke-tests/v2-app-hello-world-template/dist -55.1%↓
🤖 This report was automatically generated by wyvox/pkg-size |
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>
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>
Contributor
|
So far: It turns out, by just re-arranging the same code a bit:
|
This was referenced May 3, 2026
c00601d to
0f20060
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 classicv2-app-templateslightly smaller too (-9.50 KB raw / -2.73 KB gzip on the main chunk).Pattern
Every change uses one of three shapes:
setup-registryimports — bundles that don't pull insetup-registry(therenderComponent-only path) skip the registration and the heavy module it pulls in.Wrapped up with a
sideEffectsfield onember-source/package.jsonlisting the actual side-effect files, so bundlers can tree-shake the rest of the graph aggressively.Changes
1. Lazy
-mountand-outletkeyword registrationresolver.tsno longer statically importsmountHelper/outletHelper. Replaced withregisterBuiltInKeywordHelper(name, helper)and a side-effect filesyntax/register-routing-keywords.tsimported bysetup-registry.ts. Drops ~138 KB raw of@ember/routing+ ~7 KB of@ember/enginefrom the renderer-only path.2. Split classic
Renderersubclass intoclassic-renderer.tsMoved
Renderer extends BaseRenderer,ClassicRootState, the concreteDynamicScopeclass, and theViewinterface out ofrenderer.ts. Added aRootStateinterface soRendererStateis generic over either root type.3.
RSVP.defer→ nativePromiseinrenderSettledTogether with #1, lets the bundle drop the 62 KB rsvp shared chunk entirely.
4. Curly symbols extracted to
curly-symbols.tsisCurlyManageris now a brand check (manager[CURLY_COMPONENT_BRAND] === true) instead of an instance check — so the resolver no longer pullscomponent-managers/curly.ts(the fullCurlyComponentManagerlifecycle, ~17 KB) just to identify the manager.5. Classic
Componentclass side-effects moved toregister-curly-component.tssetInternalComponentManager(CURLY_COMPONENT_MANAGER, Component)andComponent.reopenClass({ positionalParams: [] })no longer run at the top level ofcomponent.ts; they're in a side-effect file imported bysetup-registry.ts.6.
DebugRenderTreeImplfactory moved behind a registryEnvironmentImpl(in@glimmer/runtime) importedDebugRenderTreestatically. NewregisterDebugRenderTreeFactorylets a side-effect module supply the constructor;getDebugName(the other static reach intodebug-render-tree) moved to its own file.7. Lighter array predicate in
to-bool.tsSwitched from
isArrayfrom@ember/array(which pulls the mixin/Enumerable/Observable/computed graph) toArray.isArray(x) || isEmberArray(x).8.
contentForextracted toruntime/lib/mixins/content-for.tseach-in.tsno longer drags in theProxyMixin = Mixin.create(...)graph just for an 8-linecontentForfunction.9.
@ember/instrumentationhot path extracted tolib/internal-instrument.ts_instrumentStartandflaggedInstrumentmoved to a lib file;subscribe/unsubscribe/instrumentmachinery (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/mixinaddMixin/hasMixin/forEachMixinswere@internalMeta methods called only frommixin.ts. Moved them out asmetaAddMixin/metaHasMixin/metaForEachMixinsstandalone functions there, soMeta(reachable from the renderer through the property accessor / tag chain) no longer references the classicMixinmachinery.11.
sideEffectsfield onember-source/package.jsonAdded a
sideEffectsarray 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'sactiondecorator extracted to@ember/object/actionMoved the
actiondecorator 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.tsThe 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.moduleSideEffectscallback at the rollup levelThe package-level
sideEffects: falsedeclaration was getting lost when rolldown emitted shared chunks — code from@glimmer/debug/@glimmer/debug-util/@glimmer/local-debug-flagswas leaking into chunks that the renderer-only path then pulled in. Added aPURE_INTERNAL_PACKAGESlist inrollup.config.mjswhose modules getmoduleSideEffects: falseeven 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 fromsetup-registry.ts. Removes the static import of./helperfromresolver.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, forgetChainTagsForKey)events.ts(transitively, foraddListener/sendEvent)decorator.ts(for theCOMPUTED_SETTERSWeakSet)…even though a Glimmer-only app never installs an observer or a classic computed setter. Three independent reach points were responsible:
property_events.ts→observer.ts(syncflushSyncObserversetc.)runloop/index.ts→observer.ts(asyncflushAsyncObserversper render-loop iteration)property_set.ts→decorator.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 insideobserver.tsanddecorator.tsthemselves. 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:
APPEND_OPCODESevaluation@ember/-internals/glimmerbackburner.js+@ember/runloopelement-builder@ember/-internalsAlmost 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 lintcleanpnpm type-check:internals/:types/:handlebarspasspnpm test:node20/20pnpm test:blueprints265/265smoke-tests/v2-app-template(classic v2 app) builds + 1/1 test passessmoke-tests/app-template(v1 app) builds + 1/1 test passessmoke-tests/v2-app-hello-world-templatebuilds and shrinks as reportedpnpm vite build --mode development --minify false(full dev test suite) succeeds🤖 Generated with Claude Code