Add a modern-only build variant of ember-source#21504
Draft
wagenet wants to merge 26 commits into
Draft
Conversation
A minimal owned event emitter matching the observable semantics of the Evented mixin (reverse-order dispatch, once-removal before invocation, host as default target, string method resolution), so framework classes can expose the classic on/one/off/trigger/has API by composition instead of inheriting from Evented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Granular build-time flags (CLASSIC_OBJECT_MODEL, CLASSIC_COMPONENTS, CONTROLLER_QUERY_PARAMS) gating legacy sections of Ember, folded by babel-plugin-debug-macros like @ember/canary-features. The broccoli plugin validates flag combinations (classic components require the classic object model) and parses EMBER_LEGACY_FLAGS for custom builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EmberRouter and RouterService extend FrameworkObject directly instead of EmberObject.extend(Evented); the Evented API is preserved by real on/one/off/trigger/has methods backed by an owned EventedEmitter, so routeWillChange/routeDidChange subscriptions are unchanged. The reopen blocks are folded into class bodies (prototype-assigned defaults keep .extend() overrides working), the url computed becomes a native getter, and router URL state (currentURL, currentRouteName, currentPath, currentRoute, currentState, targetState) becomes @Tracked. RouterService's and RoutingService's readOnly aliases over that state become native getters. LinkTo previously invalidated via the alias tag (tagFor(routing, 'currentState')), which nothing dirties once the aliases are gone, so it now entangles by reading routing.currentState. Array-valued query params go through a new makeQPArray seam so variant builds can drop the EmberArray wrapping. Overriding init on the router now requires calling super.init, the same contract as every other framework object. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route extends FrameworkObject instead of EmberObject.extend(ActionHandler, Evented): ActionHandler's send() is implemented directly, activate/deactivate events go through an owned EventedEmitter, the 200-line reopen is folded into the class body with prototype-assigned defaults (mergedProperties reproduces what ActionHandler and the reopen contributed, so classic .extend() keeps merging actions/queryParams), and the @computed _store/_qp getters become a plain getter and a per-instance memo respectively. Controller's behavior moves into a native class in -base.ts: init, send(), _qpChanged, and a model accessor whose setter skips notification for already-cached values and is registered in COMPUTED_SETTERS (like @Tracked setters) so metal set takes the fast path — the DEBUG mandatory-setter pre-read otherwise consumes the tag mid-write and trips the backtracking-rerender assertion on {{mount}}'s model updates. The classic ControllerMixin remains public API, defined in index.ts but no longer applied to the class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cher Namespace extends FrameworkObject, Engine extends Namespace directly, and EngineInstance extends FrameworkObject; the RegistryProxy and ContainerProxy mixins become plain method bags (owner-proxies.ts) assigned onto the prototypes, with the public types still provided by the interface merges. buildInitializerMethod drops reopenClass in favor of direct static assignment. EventDispatcher registration and setup are gated behind the CLASSIC_COMPONENTS flag: the dispatcher exists solely to drive classic components' element event methods, so builds without classic components compile it out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The action decorator moves to @ember/object/-action (re-exported from the @ember/object barrel), so it no longer shares a module with EmberObject/computed/observer. CHAIN_PASS_THROUGH moves out of chain-tags into its own module so tracked's import closure stops pulling the observer-chain machinery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three Location classes and ContainerDebugAdapter extend
FrameworkObject (their init overrides now call super.init — HashLocation
previously skipped it and HistoryLocation used this._super, a silent
no-op in a native class). typeOf's CoreObject detection moves behind a
classic-detect seam so variant builds can swap it, and {{mount}} sets
the controller model via imported set instead of Observable's .set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Alternate implementations swapped in at the build level for variants without the classic object model or classic components: - FrameworkObject as a plain native class satisfying the container's static create(props) protocol and the destroyable lifecycle, with the super.init contract preserved in DEBUG - @ember/object, @ember/object/internals, @ember/component, @ember/controller, and the glimmer/views/runtime barrels minus their classic exports (dropping the glimmer Component export is what removes the curly component implementation from the rolled-up graph) - a computed-free @service/@controller injection (plain prototype getter with per-instance caching, settable for tests) - no-op EventDispatcher (kept because @ember/test-helpers instantiates one for mock owners), plain-array query params, classic-detect stubs, a contentFor stub, and a test Adapter with a minimal extend/create protocol (ember-qunit subclasses it via Adapter.extend with an init that never calls super, so it must not inherit the framework-object init contract) ember-testing exposes its subpaths and imports the adapter via a bare specifier so the swap can apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each legacy-features flag owns three coordinated build levers in
rollup.config.mjs: module swaps (applied to bare import specifiers at
resolve time), entrypoint swaps (replacing a published module's
implementation), and entrypoint prunes. Disabling a section activates
all of them; the modern build (all flags off except
CONTROLLER_QUERY_PARAMS) emits to dist/modern/{dev,prod}, selected via a
nested ember-modern export condition so Embroider/Vite apps opt in with
resolve.conditions. EMBER_LEGACY_FLAGS builds custom combinations to
dist/custom.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bin/assert-modern-dist.mjs scans dist/modern/prod for identifiers unique to the classic modules (comments stripped; assert strings are useless as markers since prod strips them), with the query-params observer machinery explicitly allowlisted until controllers can be removed. smoke-tests/v2-app-modern-template is v2-app-template opted into the ember-modern condition, minus @embroider/legacy-inspector-support (it imports the @ember/-internals/metal barrel, which the modern variant does not ship), plus integration coverage for the computed-free @service injection. Its test build currently requires a patch to @ember/test-helpers, whose mock-owner module calls EmberObject.extend at module scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…I job - addQueryParamsObservers and finalizeQueryParamChange reach the observer machinery only through @ember/routing/lib/qp-observers, the designated swap point for removing observers once a controller-free query params implementation exists (RFC emberjs#1169) - BOUNDS moves from the curly component manager to glimmer/lib/utils so the renderer stops importing classic-component territory - a modern-variant CI job builds the dists, runs the forbidden-modules scan, reports sizes, and production-builds the modern smoke app (its dev/test builds await an @ember/test-helpers fix for the module-scope EmberObject.extend in its mock-owner support) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MODERN=1 builds the curated suite in index-modern.html against the modern variant's module swaps, with the legacy-feature flags folded to their modern values (babel.test.config.mjs) and testem pointed at the modern test page. internal-test-helpers gains a modern-safe subset barrel, since its full barrel re-exports classic test-case classes. The initial allowlist — the @Glimmer VM suites, @ember/-internals/utils, and @ember/runloop — runs 3074 tests against the swapped modules; grow the index-modern.html globs as more suites become clean of classic patterns in their test code. Wired into CI as `pnpm test:modern` in the modern-variant job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
How to opt in (the ember-modern export condition), the flag/section model and custom builds, the removed-vs-replacement table with codemod pointers, known ecosystem limitations, and the verification tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reopen call and the no-op this._super in initState only work when FrameworkObject is backed by the classic object model, so the modern variant threw at module evaluation. Prototype-assigned defaults keep .extend() overrides working in the standard build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ember/test-helpers calls EmberObject.extend at module scope in its mock-owner support, which the modern variant cannot evaluate; a vite load hook serves a lazy-construction shim so dev/test builds of modern-variant apps work (removable once upstream makes the load lazy). New smoke tests: an acceptance test covering NoneLocation, LinkTo with @query, the controller query-param round trip, and router service events, plus an <Input> two-way binding test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds container, string, deprecations, meta, instrumentation, template-compiler, and the modern-clean debug/utils/routing suites to index-modern.html (3229 tests, up from 3074), exporting buildOwner, factory, and moduleForDevelopment from the modern subset of internal-test-helpers. Two router tests now use plain property access instead of router.get, which behaves identically in both builds. The modern-variant CI job runs the smoke app's dev-mode test suite now that the build-registry shim unblocks it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n script Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
renderer.ts's RootComponentDefinition import (only reachable through the classic Component's appendTo) dragged the curly component manager into the modern and custom dists. The import is now a bare specifier swapped for a throwing stub when CLASSIC_COMPONENTS is disabled, and the dist scan gained classic-component markers (CurlyComponentManager, finalEventNameMapping) so this class of leak fails CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dist scan now also indicts ObjectProxy, the -proxy machinery, MutableArray, and NativeArray, and documents why mixin-built modules need no markers of their own (they trip applyMixin/CoreObject transitively). Three comments that described code in terms of what it replaced now state the invariant directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Registry
The type-tests run against freshly built published types in CI and
caught three regressions:
- Route, EmberRouter, and RouterService lost Evented's
(name, target, method) overloads on on/one/off and the any return on
trigger. The emitter already supports targets at runtime; the
signatures now match the Evented interface again.
- Controller.model as a class-body accessor emits as an accessor in the
published types, making the common 'model' property declaration in
subclasses a TS2610 error. The accessor now lives on the prototype via
defineProperty, typed as a property through the ControllerMixin
interface merge.
- The controller Registry interface moved to -base and was re-exported
from the barrel, which silently broke every
'declare module @ember/controller { interface Registry }'
augmentation: module augmentation only merges with interfaces
declared in the augmented module. Registry is declared in the barrel
again, with a type-only re-export in the modern entrypoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The module-scope COMPUTED_SETTERS registration mutates an imported Set, which rollup rightly treats as a cross-module side effect — pinning the module (and the Controller class) in any bundle. The model accessor definition and its setter registration now run in a class static block, so both drop with the class when nothing uses Controller. Caught by the tree-shakability snapshot test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
📊 Size reportTarball size — dist/dev 1%↑
Show files (44 files)
dist/prod 1%↑
Show files (41 files)
smoke-tests/v2-app-template/dist 1%↑
smoke-tests/v2-app-hello-world-template/dist 0.03%↑
🤖 This report was automatically generated by wyvox/pkg-size from: https://github.com/emberjs/ember.js/actions/runs/29518464187/job/87689314974?pr=21504 |
They only ever load through a variant's module/entrypoint swaps, so emitting them into dist/dev and dist/prod added ~10 kB of dead files to the standard build (flagged by the CI size report). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The build writes this manifest from the standard dist's entrypoints, so the previous commit's input prune has to land here too (caught by the package preparation CI check). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
Author
|
@NullVoxPopuli the build size delta is the new modern build itself. |
The report's app-level sections are where the modern variant should show up, but both apps consumed the standard build. The size job now also builds the modern full-app template and a new hello-world clone that opts into the ember-modern condition. Locally: hello-world 132.3 kB -> 117.8 kB (-11%), full app 352.3 kB -> 269.2 kB (-24%). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rather than a cloned app, the size report builds the same hello-world template twice — the second time with EMBER_MODERN=1 setting the ember-modern resolve condition, emitted to dist-modern. The full-app comparison keeps its own template: v2-app-template is regenerated from the blueprint on a schedule, and its inspector support cannot build against the modern variant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
Author
|
Note Refreshed size report from the latest run — fork PRs can't auto-update the comment, so this is the CI output relayed by Claude on @wagenet's behalf. Changes since the report above: the 📊 Size reportTarball size — dist/dev 0.8%↑
Show files (24 files)
dist/prod 0.9%↑
Show files (23 files)
smoke-tests/v2-app-template/dist 1%↑
smoke-tests/v2-app-hello-world-template/dist 0.03%↑
smoke-tests/v2-app-modern-template/dist ∞%↑
smoke-tests/v2-app-hello-world-template/dist-modern ∞%↑
🤖 This report was automatically generated by wyvox/pkg-size |
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.
This adds a second, opt-in build of ember-source that contains only modern (Octane and later) patterns. The classic object model —
EmberObject,Mixin, computed properties, observers-as-public-API, the proxies,EmberArray,Evented— and classic components are absent from its module graph entirely. Full documentation is inMODERN-BUILD.md.Opening as a draft to gather feedback on the approach before polishing further. Some of what's here (particularly the removal semantics and eventual deprecation story) would need to go through the RFC process before any of it ships as more than an experiment.
What's in the box
dist/modern/{dev,prod}, published inside the regularember-sourcepackage under anember-modernexport condition. Apps opt in withresolve.conditions: ['ember-modern', ...]in their Vite config; nothing changes for anyone who doesn't.@ember/legacy-features: three flags (CLASSIC_OBJECT_MODEL,CLASSIC_COMPONENTS,CONTROLLER_QUERY_PARAMS), each owning a coordinated set of build levers — debug-macro folding, resolve-time module swaps, and entrypoint prunes (seelegacySections()inrollup.config.mjs). Other combinations can be built locally withEMBER_LEGACY_FLAGS=... pnpm build:js.CONTROLLER_QUERY_PARAMSwith the observer machinery contained in one seam (@ember/routing/lib/qp-observers.ts). When a Route Manager-based QP replacement exists (post-RFCjQuery.extend(deep, [])fails withEmber.USE_ACCESSORS(infinite loop) #1169), removing them is a flag flip rather than a re-plumbing.Mainline changes
The first half of the commits de-entangle framework classes from the classic object model in a way that's behavior-neutral for the regular build (
FrameworkObjectstill extendsEmberObjectthere):FrameworkObjectdirectly, with module-scopereopen()calls folded into class bodies or prototype-assigned defaults.Eventedusage is replaced by an owned emitter behind the same publicon/one/off/trigger/hasmethods.currentURL,currentRoute, etc.) is@tracked, with native getters replacing thereadOnlyaliases.The modern build then swaps a small number of files at the rollup resolve level — most importantly
FrameworkObjectitself for a native class that satisfies the container'sstatic create(props)protocol via@glimmer/destroyable.All breaks are limited to legacy imports and are intended to be codemoddable; the table in
MODERN-BUILD.mdmaps each removal to its replacement. The one semver edge worth calling out for the regular build:init()overrides on framework classes (e.g. custom Locations) must callsuper.init(...arguments)— this was already the documented contract, and it's now asserted in debug builds.Verification
pnpm test:modern: 3229 tests running against the modern variant's actual module swaps and folded flags (curated allowlist inindex-modern.html, designed to grow).bin/assert-modern-dist.mjs: CI-enforced scan asserting no classic-module identifiers reachdist/modern/prod.smoke-tests/v2-app-modern-template: a real Embroider/Vite app consuming the variant, with acceptance tests covering routing,<LinkTo>with query params, controller QP sync, router service events, service injection, and<Input>.modern-variantCI job runs all of the above.Known limitations
@ember/test-helperscallsEmberObject.extendat module scope in its mock-owner support, which breaks dev/test builds of modern-variant apps. The smoke app ships a lazy-construction shim for that one module as a workaround; the real fix belongs upstream.DataAdapterdepends on the classic object model).Best reviewed commit by commit; each one landed with the full suite green.
🤖 Generated with Claude Code