Skip to content

Shrink hello-world bundle from 251 KB to 174 KB (-30%)#21360

Draft
NullVoxPopuli-ai-agent wants to merge 2 commits intoemberjs:mainfrom
NullVoxPopuli-ai-agent:claude/shrink-hello-world-from-main
Draft

Shrink hello-world bundle from 251 KB to 174 KB (-30%)#21360
NullVoxPopuli-ai-agent wants to merge 2 commits intoemberjs:mainfrom
NullVoxPopuli-ai-agent:claude/shrink-hello-world-from-main

Conversation

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Contributor

Cuts the hello-world smoke test (`smoke-tests/v2-app-hello-world-template`) from 251.05 KB / 79.75 KB gzip → 174.28 KB / 55.76 KB gzip — a 30% gzip reduction — while leaving the classic `v2-app-template` building and tests passing.

hello-world raw hello-world gzip classic v2-app raw classic v2-app gzip
before 251.05 KB 79.75 KB 317.17 KB 98.38 KB
after 174.28 KB 55.76 KB 317.17 KB 98.38 KB

The win comes from breaking specific dependency chains that pulled the classic-Ember-object stack (Mixin, Observable, Evented, classic Component, computed, observers, RSVP, routing) into the renderer-only path even when an app uses only `@glimmer/component` + `@tracked`.

Note

This is a re-application against `main` of the work originally landed as #21359 (which targets #21350). The deeper barrel-import refactor in #21350 is not included here; if both PRs land, a follow-up could go further by combining them — the current expectation on top of #21350 is hello-world at ~109 KB / ~34 KB gzip (-55%). This PR is the self-contained subset that works against `main` today.

Pattern

Three shapes recur:

  1. 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. 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. 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.

Commits

  1. Add deep-path wildcard exports to package.json files — adds `"./": "./.ts"` to every internal @ember and @Glimmer package's exports map, so deep imports such as `@ember/-internals/metal/lib/property_set` resolve. Ported from Remove barrel file imports from internal code for better tree-shaking #21350 (the package.json subset only; no source-file changes from that PR are included).
  2. Shrink hello-world bundle from 251 KB to 174 KB (-30%) — the actual structural changes (15 sub-changes, listed below).

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 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. `@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.

11. 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).

12. `treeshake.moduleSideEffects` callback at the rollup level

The package-level `sideEffects: false` declaration was getting lost when rolldown emitted shared chunks. Added a `PURE_INTERNAL_PACKAGES` list in `rollup.config.mjs` whose modules get `moduleSideEffects: false` even after chunking, so leaked debug code drops out of the renderer-only path entirely.

13. 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.

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

Replaced the static imports of `observer.ts` (sync flush) and `decorator.ts` (`COMPUTED_SETTERS`) with registration hooks; `observer.ts` and `decorator.ts` register themselves via top-level side effects when loaded. Drops `observer` / `events` / `chain-tags` / `decorator` chunks entirely from Glimmer-only bundles.

15. `sideEffects` field on `ember-source/package.json`

Lists 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.

Test plan

  • `pnpm lint` clean
  • `pnpm type-check:internals` clean (the 2 pre-existing failures in `@ember/-internals/owner/type-tests/owner.test.ts` and `@ember/owner/type-tests/owner.test.ts` exist on plain `main` too)
  • `pnpm test:node` 20/20
  • `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
  • Browser tests pass in CI

🤖 Generated with Claude Code

NullVoxPopuli and others added 2 commits May 3, 2026 00:32
Adds `"./*": "./*.ts"` to the `exports` map of every internal
@ember and @Glimmer package. This enables deep imports such as
`@ember/-internals/metal/lib/property_set` to resolve against
the source file directly.

Required by the follow-up commit, which uses deep imports to
break specific dependency chains that the renderer-only path
should not pay for. With only the barrel exports available,
those imports would need to go through `index.ts`, dragging in
the full barrel transitively.

Ported from emberjs#21350 (the package.json subset only;
no source-file changes from that PR are included).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cuts the hello-world smoke test (`smoke-tests/v2-app-hello-world-template`)
from 251.05 KB / 79.75 KB gzip -> 174.28 KB / 55.76 KB gzip while
leaving classic `v2-app-template` builds + tests passing.

The win comes from breaking specific dependency chains that pulled
the classic-Ember-object stack (Mixin, Observable, Evented, classic
Component, computed, observers, RSVP, routing) into the renderer-only
path even when an app uses only `@glimmer/component` + `@tracked`.

## Pattern

Three shapes recur:

1. **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. **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. **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 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`** - the
   `setInternalComponentManager(CURLY_COMPONENT_MANAGER, Component)`
   and `Component.reopenClass({ positionalParams: [] })` calls 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. **`@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.

11. **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).

12. **`treeshake.moduleSideEffects` callback at the rollup level** -
    the package-level `sideEffects: false` declaration was getting
    lost when rolldown emitted shared chunks. Added a
    `PURE_INTERNAL_PACKAGES` list in `rollup.config.mjs` whose
    modules get `moduleSideEffects: false` even after chunking,
    so leaked debug code drops out of the renderer-only path
    entirely.

13. **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.

14. **Decouple property_events / runloop / property_set from the
    observer chain** - replaced the static imports of `observer.ts`
    (sync flush) and `decorator.ts` (`COMPUTED_SETTERS`) with
    registration hooks; `observer.ts` and `decorator.ts` register
    themselves via top-level side effects when loaded. Drops
    `observer` / `events` / `chain-tags` / `decorator` chunks
    entirely from Glimmer-only bundles.

15. **`sideEffects` field on `ember-source/package.json`** - lists
    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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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