Skip to content

RFC#1234 & RFC#1247: Make Service a plain class with EMBER_DROP_CLASSIC_FEATURES - #21737

Draft
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/plain-service
Draft

NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:nvp/plain-service

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Merge #21726 first. This PR sits on top of it. Review only the last commit, b2e0f39e7e.

With EMBER_DROP_CLASSIC_FEATURES=true, Service from @ember/service is a plain class. It extends no other class.

import Service, { service } from '@ember/service';

export default class SessionService extends Service {
  @service router;

  constructor(owner) {
    super(owner);
    this.socket = new WebSocket('/session');
  }

  willDestroy() {
    super.willDestroy(...arguments);
    this.socket.close();
  }
}

Behavior with the flag

  • The owner passes itself to the constructor, as it does for Glimmer components. getOwner(this) works in the constructor.
  • @service injection works, because it only needs the owner.
  • willDestroy stays the teardown hook. The plain class has no destroy method. When the owner is destroyed, or on unregister, the container calls willDestroy(), then destroy() from @ember/destroyable. isDestroyed(service) is then true, and destructors from registerDestructor run.
  • The container knows the plain class by a mark from setPlainFrameworkClass, set only in the flag build. Other instances keep the destroy() call.
  • super.willDestroy() works, because the base has an empty willDestroy().
  • init, get, set, extend, reopen, destroy, isDestroying and isDestroyed do not exist on the class.
  • A static create stays, because the container creates every instance through it.

Without the flag, Service does not change. A cast keeps the published type as the classic class, so apps get no new type errors.

Internal services

RouterService and the private RoutingService extend the exported class, so they run on the plain class in the flag build. They needed no source changes. The willDestroy hook of RouterService runs in both builds.

Tests

Change Why
New @ember/service/tests/drop_classic_features_test.js Checks the plain class: no base class, owner in the constructor, injection, the willDestroy hook. Registers only with the flag.
New ember/tests/without-EMBER_DROP_CLASSIC_FEATURES/service_test.js Checks the classic API in builds without the flag: the EmberObject base, init, Service.extend, properties from create, get/set, willDestroy, the destroy flags, and get() on the router service. Registers only without the flag.
init() to constructor(owner) in three tests Works on both base classes
service.get('x') to service.x in three router service test files Works on both base classes
serviceInstance.set(...) to set(serviceInstance, ...) Works on both base classes
The ProxyMixin service test skips with the flag It uses Service.extend

Checks

Mode Result
Default 9241 tests, 0 failures
ALL_DEPRECATIONS_ENABLED=true 9241 tests, 0 failures
OVERRIDE_DEPRECATION_VERSION=15.0.0 8264 tests, 0 failures
Production build 9078 tests, 0 failures
EMBER_DROP_CLASSIC_FEATURES=true 9241 tests, 0 failures

The type checks of the internals and the published types pass, with and without stable decorators. eslint, prettier and the docs check pass.

🤖 Generated with Claude Code

NullVoxPopuli and others added 2 commits October 17, 2026 16:06
`import.meta.env?.EMBER_DROP_CLASSIC_FEATURES` is a build-time flag for
RFC 1247. Later changes read it where a classic feature is defined, and
remove that feature from builds that set the flag.

- `ImportMetaEnv` declares the flag for the type checks.
- `EMBER_DROP_` is in the Vite `envPrefix`, so
  `EMBER_DROP_CLASSIC_FEATURES=true pnpm vite build` sets it for the tests.
- CI has a "Classic features dropped" variant that builds and tests with
  the flag.
- The hello-world smoke test sets the flag with a Vite `define` entry, so
  the package-size workflow measures an app without the classic features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the flag, `@ember/service` exports a `Service` class that extends no
other class. The owner passes itself to the constructor, as it does for
Glimmer components. A static `create` is the only entry point that the
container needs. Without the flag, `Service` stays the same, and a cast
keeps the published types.

`willDestroy` stays the teardown hook. The plain class has no `destroy`
method. The container calls `willDestroy`, then the `destroy` function of
`@ember/destroyable`, for classes marked with `setPlainFrameworkClass`.
Other instances keep the `destroy()` call.

The internal router and routing services extend the exported class, so they
run on the plain class in the flag build.

Tests:
- Three tests that used `init()` on a service now use a constructor.
- The router service tests read properties without `get()`.
- `set(service, ...)` replaces `service.set(...)` in one test.
- The `ProxyMixin` service test skips with the flag.
- A flag-only module checks the plain class.
- `packages/ember/tests/without-EMBER_DROP_CLASSIC_FEATURES/service_test.js`
  checks the classic API in builds without the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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