From 60a4066d84661e1bee4f51a9272716e5b3d1048c Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:10:06 -0700 Subject: [PATCH 01/24] Draft RFC: per-deprecation early enablement and deprecation shaking Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eprecation-early-enablement-and-shaking.md | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 internal-docs/rfcs/deprecation-early-enablement-and-shaking.md diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md new file mode 100644 index 00000000000..fa5ba0733bf --- /dev/null +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -0,0 +1,276 @@ +--- +stage: draft +start-date: 2026-07-16 +release-date: +release-versions: +teams: + - framework + - learning +prs: + accepted: +project-link: +--- + +# Per-Deprecation Early Enablement and Deprecation Shaking + +## Summary + +Two connected additions to Ember's deprecation system, building on the staging +model from [RFC 0649](https://rfcs.emberjs.com/id/0649-deprecation-staging/): + +1. **Per-deprecation stage configuration.** Apps can turn on individual + "available"-stage deprecations before they reach the "enabled" stage, and + can declare *compliance* — making deprecations they have already migrated + away from throw instead of warn, so they can't creep back in. +2. **Deprecation shaking.** A build-time mechanism to strip deprecated *code + paths* — not just the warning calls — from an app's bundle, configured + per-deprecation or by compliance version. + +## Motivation + +RFC 0649 gave deprecations a two-stage lifecycle: `available` (merged, but off +by default in apps) and `enabled`. In practice the `available` stage is inert +today: `ember-source` ships available-stage deprecations, but an app has no +supported way to see them. The only switch is the internal, all-or-nothing +`EmberENV._ALL_DEPRECATIONS_ENABLED`, which is unusable in real apps — turning +on *every* in-flight deprecation at once produces noise no team can act on. + +This blocks a workflow the framework increasingly needs: **merging +deprecations before they are fully approved for enablement**. Deprecating a +large legacy surface (for example, the classic object model) requires landing +deprecation calls incrementally, letting early adopters and app CI opt in +per-deprecation to get signal, and only later flipping each one to `enabled`. +Without per-id opt-in, every deprecation must be born fully enabled, which +makes large deprecation efforts all-or-nothing. + +The second gap is on the other end of the lifecycle. Once an app has migrated +off a deprecated API, today it gets nothing back: + +- Nothing *prevents backsliding*. RFC 0649 explicitly anticipated letting + users opt in to deprecations becoming assertions; this RFC specifies that. +- Nothing *removes the code*. The deprecated implementation ships in every + app bundle until the next Ember major, even for apps that provably don't + use it (their CI would throw if they did). Both RFC 0649 and + [RFC 0830](https://rfcs.emberjs.com/id/0830-evolving-embers-major-version-process/) + name "deprecation shaking" / compiling away deprecated features as intended + future work; neither specifies it. The old `@ember/deprecated-features` + package (the "svelte" effort, see + [RFC PR #512](https://github.com/emberjs/rfcs/pull/512) discussion) was an + earlier attempt whose build-integration story predates Embroider and + prebuilt ESM dists. + +Compliance-that-throws is what makes shaking safe: a deprecation that throws +when used cannot be depended on, so its implementation can be removed and the +app keeps working. + +## Detailed design + +### Part 1: `EmberENV.DEPRECATION_STAGES` + +A new `EmberENV` key configures deprecation behavior for the app. It is read +once at boot (before any deprecation can fire) and is development-only: in +production builds `deprecate` is already compiled away and this configuration +has no effect. + +```ts +interface DeprecationStagesConfig { + /** + * Turn on available-stage deprecations early. + * `true` enables all of them; an array enables specific ids. + */ + enable?: true | string[]; + + /** + * Compliance declaration: "we do not use any deprecated API that was + * enabled as of this version of this package." Any deprecation from that + * package whose `since.enabled` is <= the declared version *throws* + * instead of warning. A bare string is shorthand for + * `{ 'ember-source': version }`. + */ + compliance?: string | Record; + + /** + * Individual deprecation ids that should throw when triggered, regardless + * of stage. This is how an app locks in migration away from an + * available-stage deprecation it opted into via `enable`. + */ + assert?: string[]; + + /** + * Escape hatch: ids exempted from `compliance`/`assert` throwing. + */ + except?: string[]; +} +``` + +Example `config/environment.js`: + +```js +EmberENV: { + DEPRECATION_STAGES: { + enable: ['ember-source.classic-object-model'], + compliance: '6.8.0', + assert: ['some-migrated.available-stage-id'], + except: ['deprecation-we-are-still-working-on'], + }, +} +``` + +Semantics: + +- `enable` affects only whether a deprecation *fires* (it flips the + available-stage suppression off for the listed ids). It composes with the + existing `since: { available, enabled }` metadata from RFC 0649: + enabled-stage deprecations always fire; available-stage deprecations fire + if listed (or `enable: true`). +- Throwing (via `compliance`/`assert`) happens in `deprecate` itself, before + the handler chain, mirroring the existing behavior of deprecations past + their `until` version. It therefore applies to *all* deprecations flowing + through `@ember/debug` — including addon deprecations with their own `for` + — not only Ember's own. +- Precedence: `except` > `assert` > `compliance`. +- A compliance declaration for a package version newer than the installed + version is invalid (asserts), mirroring RFC 0649's rule against optimistic + declarations. +- `_ALL_DEPRECATIONS_ENABLED` becomes an alias for `enable: true` and is + eventually deprecated itself. + +Relationship to existing tools: `registerDeprecationHandler` and +ember-cli-deprecation-workflow continue to control how *warnings* are +reported/silenced. `DEPRECATION_STAGES` controls which deprecations exist at +all for this app (fire early / throw). Workflow files remain the right tool +for triaging warnings; compliance is the tool for locking in finished +migrations. + +A private `setDeprecationStagesConfig()` API allows test harnesses to swap +configuration at runtime; it is not (yet) public API. + +### Part 2: Deprecation shaking + +#### Guard convention in ember-source + +Every *shakable* deprecation gets a boolean flag constant in +`@ember/deprecated-features`, named identically to its entry in Ember's +internal `DEPRECATIONS` registry: + +```ts +// @ember/deprecated-features +/** id: deprecate-comparable-mixin, since: 7.2.0/7.2.0, until: 7.5.0 */ +export const DEPRECATE_COMPARABLE_MIXIN = true; +``` + +Deprecated code paths are guarded by the flag, with the deprecation call +inside the guard and the post-removal behavior in the other branch: + +```ts +import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; + +const Comparable = DEPRECATE_COMPARABLE_MIXIN + ? Mixin.create({ + init() { + deprecateUntil(msg, DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN); + // ... + }, + compare: null, + }) + : undefined; // post-removal shape +``` + +The registry entry is linked to the flag, so when the flag is `false` the +deprecation reports itself as *removed*: any unguarded reach of the API +throws the same "has been removed" error that shipping past `until` would +produce. This makes shaking a pure size optimization layered on +already-correct runtime semantics — a build in which the flags are `false` +behaves identically whether or not the guarded code was actually stripped. + +#### How the flags reach apps + +`ember-source`'s published dist keeps the flag module *live* rather than +inlining it: every dist chunk imports the flags from a single emitted +`@ember/deprecated-features` module whose constants are all `true`. Alongside +it, the package publishes `dist/deprecation-flags.json` describing each flag +(`id`, constant name, `since`, `until`). + +Apps opt in via a build plugin published as `ember-source/deprecation-shaking`: + +```js +// vite.config / ember-cli-build +import { deprecationShaking } from 'ember-source/deprecation-shaking'; + +deprecationShaking({ + // strip everything with `until` <= this ember-source version + compliantThrough: '6.8.0', + // and/or individual flags + strip: ['deprecate-comparable-mixin'], + keep: [], +}); +``` + +The plugin replaces the flag module's contents with the computed constants. +The app's bundler then dead-code-eliminates the guarded branches in +production builds. In development the flags are simply `false` at runtime, +which — per the linkage above — yields the exact "removed" behavior, so dev +and prod agree even where a bundler's DCE is conservative. + +For those building `ember-source` from source, an +`EMBER_DEPRECATION_FLAGS` environment variable produces a custom dist with +the flags compile-time folded (the guaranteed-DCE path, also used by Ember's +own CI to verify each shakable deprecation actually leaves the bundle). + +#### What this asks of deprecation authors + +Adding a shakable deprecation means: a `DEPRECATIONS` registry entry, a flag +constant, guards following the convention above, and a bundle-scan marker in +CI. Not every deprecation must be shakable — tiny ones with no meaningful +implementation weight can remain plain runtime deprecations — but +deprecations of substantial subsystems should be. + +## How we teach this + +- Each deprecation guide entry gains an "early opt-in" snippet + (`DEPRECATION_STAGES.enable`) while the deprecation is available-stage, and + a "lock it in" snippet (`assert`/`compliance`) once migrated. +- The Configuring Ember guide gains a section on `DEPRECATION_STAGES`. +- The CLI/build guides document `ember-source/deprecation-shaking`. +- CONTRIBUTING in ember.js documents the guard convention for deprecation + authors. + +## Drawbacks + +- **Flag proliferation**: one constant per shakable deprecation, plus + registry linkage and scan markers, is real maintenance overhead in + ember-source. +- **Two sources of truth** (registry entry + flag constant) require a + conformance test to stay aligned. +- **Bundler-dependent stripping**: app-side shaking relies on the app + bundler's constant propagation and DCE. Mitigated by the runtime-false + semantics (correct behavior regardless) and by ember-source CI asserting + strippability with its own toolchain. +- The externalized flags module is a novel shape in the published dist. + +## Alternatives + +- **Export-condition build variants** (as used for prebuilt dev/prod): can't + express per-deprecation choices — the variant space is combinatorial. +- **Publish-time folding only** (the original svelte plan via + ember-cli-babel): predates prebuilt ESM dists; apps no longer re-transpile + ember-source, so publish-time folding gives apps no control at all. +- **Handler-based compliance** (build throwing on top of + `registerDeprecationHandler`): works for warnings but cannot make the + *removed* semantics (throw even in paths that suppress warnings) or feed + build-time stripping. +- **Per-id-list-only compliance** (no version form): simpler, but loses the + monotonic "compliant through X" declaration RFC 0649 designed for, where + upgrading ember-source never silently reduces your protection. + +## Unresolved questions + +- Should `setDeprecationStagesConfig` become public API for test harnesses + (e.g. ember-qunit integration), or remain private? +- Should `compliance` also cover available-stage ids the app opted into via + `enable` (currently: no — use `assert` for those)? +- Interaction with per-import factory deprecations (e.g. the + `deprecate-import-*-from-ember` family): a single flag for the family, or + none? Deferred. +- Glimmer VM deprecations use their own override table upstream; wiring them + into this system is future work. From 55c9801b5c553cd832cc4019f6f8ef6ff6c7116f Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:22:43 -0700 Subject: [PATCH 02/24] Add per-deprecation stage configuration via EmberENV.DEPRECATION_STAGES Apps can enable individual available-stage deprecations early (enable), declare compliance so migrated-away deprecations throw instead of warn (compliance/assert/except), and test harnesses can swap config at runtime via setDeprecationStagesConfig. DEPRECATIONS registry entries now compute test/isEnabled/isRemoved lazily so config changes are reflected. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 7 + index.html | 13 ++ .../@ember/-internals/deprecations/index.ts | 24 ++- .../deprecations/tests/index-test.js | 48 ++++- .../@ember/-internals/environment/lib/env.ts | 28 +++ packages/@ember/debug/index.ts | 1 + packages/@ember/debug/lib/deprecate.ts | 9 + .../@ember/debug/lib/deprecation-stages.ts | 180 ++++++++++++++++ packages/@ember/debug/package.json | 1 + .../debug/tests/deprecation-stages-test.js | 201 ++++++++++++++++++ pnpm-lock.yaml | 3 + testem.cjs | 9 + tests/docs/expected.cjs | 1 + 13 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 packages/@ember/debug/lib/deprecation-stages.ts create mode 100644 packages/@ember/debug/tests/deprecation-stages-test.js diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index 716cdf97e3d..b8556dce43b 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -104,6 +104,11 @@ jobs: - name: "All deprecations enabled, with optional features" ALL_DEPRECATIONS_ENABLED: "true" ENABLE_OPTIONAL_FEATURES: "true" + - name: "Available deprecations enabled via stage config" + ENABLED_DEPRECATIONS: "true" + - name: "Deprecation compliance declared" + DEPRECATION_COMPLIANCE: "7.2.0" + RAISE_ON_DEPRECATION: "false" - name: "Deprecations as errors" OVERRIDE_DEPRECATION_VERSION: "15.0.0" - name: "Deprecations as errors, with optional features" @@ -127,6 +132,8 @@ jobs: - name: test env: ALL_DEPRECATIONS_ENABLED: ${{ matrix.ALL_DEPRECATIONS_ENABLED }} + ENABLED_DEPRECATIONS: ${{ matrix.ENABLED_DEPRECATIONS }} + DEPRECATION_COMPLIANCE: ${{ matrix.DEPRECATION_COMPLIANCE }} OVERRIDE_DEPRECATION_VERSION: ${{ matrix.OVERRIDE_DEPRECATION_VERSION }} ENABLE_OPTIONAL_FEATURES: ${{ matrix.ENABLE_OPTIONAL_FEATURES }} RAISE_ON_DEPRECATION: ${{ matrix.RAISE_ON_DEPRECATION }} diff --git a/index.html b/index.html index 77ad682afac..95971d4a273 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,19 @@ EmberENV['_OVERRIDE_DEPRECATION_VERSION'] = QUnit.urlParams.OVERRIDE_DEPRECATION_VERSION; } + if (QUnit.urlParams.ENABLED_DEPRECATIONS || QUnit.urlParams.DEPRECATION_COMPLIANCE) { + EmberENV['DEPRECATION_STAGES'] = {}; + if (QUnit.urlParams.ENABLED_DEPRECATIONS) { + EmberENV['DEPRECATION_STAGES'].enable = + QUnit.urlParams.ENABLED_DEPRECATIONS === 'true' + ? true + : QUnit.urlParams.ENABLED_DEPRECATIONS.split(','); + } + if (QUnit.urlParams.DEPRECATION_COMPLIANCE) { + EmberENV['DEPRECATION_STAGES'].compliance = QUnit.urlParams.DEPRECATION_COMPLIANCE; + } + } + QUnit.config.urlConfig.push({ id: 'OVERRIDE_DEPRECATION_VERSION', value: ['20.0.0', '6.0.0', '5.12.0'], diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 9b1c7e93827..f624218376c 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -1,11 +1,16 @@ import type { DeprecationOptions } from '@ember/debug/lib/deprecate'; +import { isDeprecationEnabledByConfig } from '@ember/debug/lib/deprecation-stages'; import { ENV } from '@ember/-internals/environment/lib/env'; import { VERSION } from '@ember/version'; import { deprecate, assert } from '@ember/debug'; import { dasherize } from '../string/index'; function isEnabled(options: DeprecationOptions) { - return Object.hasOwnProperty.call(options.since, 'enabled') || ENV._ALL_DEPRECATIONS_ENABLED; + return ( + Object.hasOwnProperty.call(options.since, 'enabled') || + ENV._ALL_DEPRECATIONS_ENABLED || + isDeprecationEnabledByConfig(options.id) + ); } let numEmberVersion = parseFloat(ENV._OVERRIDE_DEPRECATION_VERSION ?? VERSION); @@ -27,12 +32,21 @@ interface DeprecationObject { isRemoved: boolean; } -function deprecation(options: DeprecationOptions) { +// Getters rather than snapshots: registry entries are created at module +// eval, but stage configuration can change afterwards (e.g. test harnesses +// calling setDeprecationStagesConfig). +export function deprecation(options: DeprecationOptions): DeprecationObject { return { options, - test: !isEnabled(options), - isEnabled: isEnabled(options) || isRemoved(options), - isRemoved: isRemoved(options), + get test() { + return !isEnabled(options); + }, + get isEnabled() { + return isEnabled(options) || isRemoved(options); + }, + get isRemoved() { + return isRemoved(options); + }, }; } diff --git a/packages/@ember/-internals/deprecations/tests/index-test.js b/packages/@ember/-internals/deprecations/tests/index-test.js index 5d3d0efea36..f4d97b59c8d 100644 --- a/packages/@ember/-internals/deprecations/tests/index-test.js +++ b/packages/@ember/-internals/deprecations/tests/index-test.js @@ -1,6 +1,7 @@ import { AbstractTestCase, moduleFor } from 'internal-test-helpers'; -import { deprecateUntil, isRemoved, emberVersionGte } from '../index'; +import { deprecation, deprecateUntil, isRemoved, emberVersionGte } from '../index'; import { ENV } from '@ember/-internals/environment'; +import { setDeprecationStagesConfig } from '@ember/debug'; let originalEnvValue; @@ -14,9 +15,54 @@ moduleFor( } teardown() { + setDeprecationStagesConfig(null); ENV.RAISE_ON_DEPRECATION = originalEnvValue; } + ['@test available-stage deprecations reflect stage config changes'](assert) { + let AVAILABLE_DEPRECATION = deprecation({ + id: 'test-available-stage', + until: '30.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-available-stage', + since: { available: '1.0.0' }, + }); + + assert.true(AVAILABLE_DEPRECATION.test, 'suppressed with no config'); + assert.false(AVAILABLE_DEPRECATION.isEnabled, 'not enabled with no config'); + + setDeprecationStagesConfig({ enable: ['test-available-stage'] }); + + assert.false(AVAILABLE_DEPRECATION.test, 'fires once enabled by config'); + assert.true(AVAILABLE_DEPRECATION.isEnabled, 'enabled by config'); + + setDeprecationStagesConfig({ enable: ['some-other-id'] }); + + assert.true(AVAILABLE_DEPRECATION.test, 'suppressed again when config changes'); + } + + ['@test deprecateUntil fires an available-stage deprecation enabled by config'](assert) { + let AVAILABLE_DEPRECATION = deprecation({ + id: 'test-available-fires', + until: '30.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-available-fires', + since: { available: '1.0.0' }, + }); + + expectNoDeprecation(() => { + deprecateUntil('This deprecation is suppressed', AVAILABLE_DEPRECATION); + }); + + setDeprecationStagesConfig({ enable: ['test-available-fires'] }); + + expectDeprecation(() => { + deprecateUntil('This deprecation fires', AVAILABLE_DEPRECATION); + }, /This deprecation fires/); + + assert.ok(true, 'ran without throwing'); + } + ['@test deprecateUntil throws when deprecation has been removed'](assert) { assert.expect(1); diff --git a/packages/@ember/-internals/environment/lib/env.ts b/packages/@ember/-internals/environment/lib/env.ts index 4e624f0bdef..3df7b74642d 100644 --- a/packages/@ember/-internals/environment/lib/env.ts +++ b/packages/@ember/-internals/environment/lib/env.ts @@ -117,6 +117,34 @@ export const ENV = { */ _ALL_DEPRECATIONS_ENABLED: false, + /** + Configuration for the deprecation staging system. Allows an app to enable + individual available-stage deprecations early (`enable`), and to declare + compliance with deprecations it has migrated away from so that triggering + them throws instead of warning (`compliance`, `assert`, `except`). + + ```js + EmberENV: { + DEPRECATION_STAGES: { + enable: ['some-available-stage-deprecation-id'], + compliance: '6.8.0', + assert: ['a-migrated-deprecation-id'], + except: ['a-deprecation-still-being-worked-on'], + }, + } + ``` + + Only meaningful in development builds; deprecations do not exist in + production builds. + + @property DEPRECATION_STAGES + @for EmberENV + @type Object | null + @default null + @public + */ + DEPRECATION_STAGES: null as Record | null, + /** Override the version of ember-source used to determine when deprecations "break". This is used internally by Ember to test with deprecated features "removed". diff --git a/packages/@ember/debug/index.ts b/packages/@ember/debug/index.ts index d24c1ec74cf..fc12f5b9353 100644 --- a/packages/@ember/debug/index.ts +++ b/packages/@ember/debug/index.ts @@ -14,6 +14,7 @@ export { registerHandler as registerDeprecationHandler, type DeprecationOptions, } from './lib/deprecate'; +export { setDeprecationStagesConfig, type DeprecationStagesConfig } from './lib/deprecation-stages'; export { default as inspect } from './lib/inspect'; export { isTesting, setTesting } from './lib/testing'; export { default as captureRenderTree } from './lib/capture-render-tree'; diff --git a/packages/@ember/debug/lib/deprecate.ts b/packages/@ember/debug/lib/deprecate.ts index ce32eacfbd4..c17b0dffd63 100644 --- a/packages/@ember/debug/lib/deprecate.ts +++ b/packages/@ember/debug/lib/deprecate.ts @@ -2,6 +2,7 @@ import { ENV } from '@ember/-internals/environment/lib/env'; import { DEBUG } from '@glimmer/env'; import { assert } from './assert'; +import { shouldThrowForDeprecation } from './deprecation-stages'; import type { HandlerCallback } from './handlers'; import { invoke, registerHandler as genericRegisterHandler } from './handlers'; @@ -256,6 +257,14 @@ if (DEBUG) { assert(missingOptionDeprecation(options!.id, 'for'), Boolean(options!.for)); assert(missingOptionDeprecation(options!.id, 'since'), Boolean(options!.since)); + if (!test && shouldThrowForDeprecation(options!)) { + throw new Error( + `The deprecation ${options!.id} was triggered, but this app has declared compliance with it via EmberENV.DEPRECATION_STAGES. The message was: ${message}.${ + options!.url ? ` See ${options!.url} for more details.` : '' + }` + ); + } + invoke('deprecate', message, test, options); }; } diff --git a/packages/@ember/debug/lib/deprecation-stages.ts b/packages/@ember/debug/lib/deprecation-stages.ts new file mode 100644 index 00000000000..02797d93333 --- /dev/null +++ b/packages/@ember/debug/lib/deprecation-stages.ts @@ -0,0 +1,180 @@ +import { ENV } from '@ember/-internals/environment/lib/env'; +import { VERSION } from '@ember/version'; +import { DEBUG } from '@glimmer/env'; + +import { assert } from './assert'; +import type { DeprecationOptions } from './deprecate'; + +/** + Configuration for the deprecation staging system, provided by the app via + `EmberENV.DEPRECATION_STAGES` (or swapped at runtime by test harnesses via + `setDeprecationStagesConfig`). + + Deprecations move through two stages (see `deprecate`): "available" and + "enabled". This config lets an app opt in to available-stage deprecations + early, and lock in finished migrations by turning deprecations it no longer + triggers into errors. + */ +export interface DeprecationStagesConfig { + /** + Turn on available-stage deprecations early. `true` enables all of them; + an array enables specific deprecation ids. + */ + enable?: true | string[]; + + /** + Compliance declaration: "this app does not use any deprecated API that + was enabled as of this version of this package." Any deprecation from + that package whose `since.enabled` is at or below the declared version + throws instead of warning. A bare string is shorthand for + `{ 'ember-source': version }`. + */ + compliance?: string | Record; + + /** + Deprecation ids that throw when triggered, regardless of stage. This is + how an app locks in a migration away from an available-stage deprecation + it opted into via `enable`. + */ + assert?: string[]; + + /** + Ids exempted from `compliance`/`assert` throwing. + */ + except?: string[]; +} + +let isDeprecationEnabledByConfig: (id: string) => boolean = () => false; +let shouldThrowForDeprecation: (options: DeprecationOptions) => boolean = () => false; +let setDeprecationStagesConfig: (config: DeprecationStagesConfig | null) => void = () => {}; + +if (DEBUG) { + interface NormalizedConfig { + enableAll: boolean; + enabledIds: Set; + compliance: Record; + assertIds: Set; + exceptIds: Set; + } + + // Numeric segment-wise comparison of dotted version strings. Unlike the + // parseFloat-based `until` comparison in @ember/-internals/deprecations, + // this orders multi-digit minors correctly (3.28 > 3.4), which matters + // because compliance versions are arbitrary app-supplied versions. + let compareVersions = (a: string, b: string): number => { + let aParts = a.split('.').map((part) => parseInt(part, 10)); + let bParts = b.split('.').map((part) => parseInt(part, 10)); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + let diff = (aParts[i] ?? 0) - (bParts[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; + }; + + let isVersionString = (value: unknown): value is string => + typeof value === 'string' && /^\d+(\.\d+)*$/.test(value.replace(/[-+].*$/, '')); + + let isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === 'string'); + + let normalize = (config: DeprecationStagesConfig | null | undefined): NormalizedConfig => { + let normalized: NormalizedConfig = { + enableAll: false, + enabledIds: new Set(), + compliance: {}, + assertIds: new Set(), + exceptIds: new Set(), + }; + + if (config === null || config === undefined) { + return normalized; + } + + assert( + `DEPRECATION_STAGES must be an object, got ${String(config)}`, + typeof config === 'object' + ); + + let { enable, compliance, assert: assertIds, except } = config; + + if (enable !== undefined) { + assert( + `DEPRECATION_STAGES.enable must be \`true\` or an array of deprecation ids, got ${String( + enable + )}`, + enable === true || isStringArray(enable) + ); + if (enable === true) { + normalized.enableAll = true; + } else { + normalized.enabledIds = new Set(enable); + } + } + + if (compliance !== undefined) { + let byPackage = typeof compliance === 'string' ? { 'ember-source': compliance } : compliance; + assert( + `DEPRECATION_STAGES.compliance must be a version string or an object mapping package names to version strings, got ${String( + compliance + )}`, + typeof byPackage === 'object' && byPackage !== null + ); + for (let pkg of Object.keys(byPackage)) { + let version = byPackage[pkg]; + assert( + `DEPRECATION_STAGES.compliance['${pkg}'] must be a version string, got ${String( + version + )}`, + isVersionString(version) + ); + assert( + `DEPRECATION_STAGES.compliance['ember-source'] is ${version}, which is newer than the installed ember-source (${VERSION}). Compliance cannot be declared against a version that is not installed.`, + pkg !== 'ember-source' || compareVersions(version, VERSION) <= 0 + ); + } + normalized.compliance = byPackage; + } + + if (assertIds !== undefined) { + assert( + `DEPRECATION_STAGES.assert must be an array of deprecation ids, got ${String(assertIds)}`, + isStringArray(assertIds) + ); + normalized.assertIds = new Set(assertIds); + } + + if (except !== undefined) { + assert( + `DEPRECATION_STAGES.except must be an array of deprecation ids, got ${String(except)}`, + isStringArray(except) + ); + normalized.exceptIds = new Set(except); + } + + return normalized; + }; + + let current = normalize(ENV.DEPRECATION_STAGES as DeprecationStagesConfig | null); + + isDeprecationEnabledByConfig = (id) => current.enableAll || current.enabledIds.has(id); + + shouldThrowForDeprecation = (options) => { + if (current.exceptIds.has(options.id)) { + return false; + } + if (current.assertIds.has(options.id)) { + return true; + } + let compliantVersion = current.compliance[options.for]; + if (compliantVersion !== undefined && 'enabled' in options.since) { + return compareVersions(options.since.enabled, compliantVersion) <= 0; + } + return false; + }; + + setDeprecationStagesConfig = (config) => { + current = normalize(config); + }; +} + +export { isDeprecationEnabledByConfig, shouldThrowForDeprecation, setDeprecationStagesConfig }; diff --git a/packages/@ember/debug/package.json b/packages/@ember/debug/package.json index 0d271bb0f79..cdb4a4b6b57 100644 --- a/packages/@ember/debug/package.json +++ b/packages/@ember/debug/package.json @@ -19,6 +19,7 @@ "@ember/routing": "workspace:*", "@ember/runloop": "workspace:*", "@ember/utils": "workspace:*", + "@ember/version": "workspace:*", "@glimmer/destroyable": "workspace:*", "@glimmer/env": "workspace:*", "@glimmer/interfaces": "workspace:*", diff --git a/packages/@ember/debug/tests/deprecation-stages-test.js b/packages/@ember/debug/tests/deprecation-stages-test.js new file mode 100644 index 00000000000..5794d6edf4b --- /dev/null +++ b/packages/@ember/debug/tests/deprecation-stages-test.js @@ -0,0 +1,201 @@ +import { ENV } from '@ember/-internals/environment'; +import { VERSION } from '@ember/version'; +import { deprecate, setDeprecationStagesConfig } from '../index'; +import { isDeprecationEnabledByConfig } from '../lib/deprecation-stages'; + +import { moduleForDevelopment, AbstractTestCase as TestCase } from 'internal-test-helpers'; + +const noop = function () {}; +const originalConsoleWarn = console.warn; // eslint-disable-line no-console + +function availableOptions(id, overrides = {}) { + return { + id, + for: 'ember-source', + since: { available: '6.0.0' }, + until: '7.0.0', + ...overrides, + }; +} + +function enabledOptions(id, overrides = {}) { + return availableOptions(id, { since: { available: '6.0.0', enabled: '6.1.0' }, ...overrides }); +} + +let originalRaiseOnDeprecation; + +moduleForDevelopment( + 'ember-debug: deprecation stages', + class extends TestCase { + constructor() { + super(); + originalRaiseOnDeprecation = ENV.RAISE_ON_DEPRECATION; + ENV.RAISE_ON_DEPRECATION = false; + console.warn = noop; // eslint-disable-line no-console + } + + teardown() { + setDeprecationStagesConfig(null); + ENV.RAISE_ON_DEPRECATION = originalRaiseOnDeprecation; + console.warn = originalConsoleWarn; // eslint-disable-line no-console + } + + ['@test no config: nothing is enabled or thrown'](assert) { + setDeprecationStagesConfig(null); + + assert.false(isDeprecationEnabledByConfig('some-id'), 'no id is enabled'); + deprecate('enabled-stage deprecation warns without throwing', false, enabledOptions('e1')); + assert.ok(true, 'no throw'); + } + + ['@test enable: true enables every id'](assert) { + setDeprecationStagesConfig({ enable: true }); + + assert.true(isDeprecationEnabledByConfig('anything')); + assert.true(isDeprecationEnabledByConfig('anything-else')); + } + + ['@test enable: [ids] enables only the listed ids'](assert) { + setDeprecationStagesConfig({ enable: ['listed-id'] }); + + assert.true(isDeprecationEnabledByConfig('listed-id')); + assert.false(isDeprecationEnabledByConfig('other-id')); + } + + ['@test compliance version string throws for enabled deprecations at or below it'](assert) { + setDeprecationStagesConfig({ compliance: '6.1.0' }); + + assert.throws( + () => deprecate('at compliance version', false, enabledOptions('at-version')), + /declared compliance/, + 'since.enabled === compliance version throws' + ); + assert.throws( + () => + deprecate( + 'below compliance version', + false, + enabledOptions('below-version', { since: { available: '5.0.0', enabled: '6.0.0' } }) + ), + /declared compliance/, + 'since.enabled < compliance version throws' + ); + + deprecate( + 'above compliance version', + false, + enabledOptions('above-version', { since: { available: '6.1.0', enabled: '6.2.0' } }) + ); + deprecate('available-stage is unaffected', false, availableOptions('still-available')); + assert.ok(true, 'newer and available-stage deprecations do not throw'); + } + + ['@test compliance orders multi-digit versions numerically, not lexically'](assert) { + setDeprecationStagesConfig({ compliance: { 'ember-source': '3.28.0' } }); + + assert.throws( + () => + deprecate( + 'multi-digit minor', + false, + enabledOptions('multi-digit', { since: { available: '3.4.0', enabled: '3.10.0' } }) + ), + /declared compliance/, + '3.10.0 <= 3.28.0' + ); + + deprecate( + 'not yet compliant', + false, + enabledOptions('newer-minor', { since: { available: '4.0.0', enabled: '4.4.0' } }) + ); + assert.ok(true, '4.4.0 > 3.28.0 does not throw'); + } + + ['@test compliance is scoped per package via the object form'](assert) { + setDeprecationStagesConfig({ compliance: { 'some-addon': '2.0.0' } }); + + assert.throws( + () => + deprecate( + 'addon deprecation', + false, + enabledOptions('addon-dep', { + for: 'some-addon', + since: { available: '1.0.0', enabled: '1.5.0' }, + }) + ), + /declared compliance/, + 'matches the declared package' + ); + + deprecate('ember-source deprecation', false, enabledOptions('ember-dep')); + assert.ok(true, 'other packages are unaffected'); + } + + ['@test assert throws per-id regardless of stage'](assert) { + setDeprecationStagesConfig({ assert: ['locked-in'], enable: ['locked-in'] }); + + assert.throws( + () => deprecate('available-stage locked in', false, availableOptions('locked-in')), + /declared compliance/ + ); + + deprecate('unlisted id still warns', false, enabledOptions('unlisted')); + assert.ok(true, 'unlisted ids do not throw'); + } + + ['@test except exempts an id from compliance and assert'](assert) { + setDeprecationStagesConfig({ + compliance: '6.1.0', + assert: ['asserted-but-excepted'], + except: ['excepted', 'asserted-but-excepted'], + }); + + deprecate('excepted from compliance', false, enabledOptions('excepted')); + deprecate('excepted from assert', false, enabledOptions('asserted-but-excepted')); + assert.ok(true, 'excepted ids do not throw'); + + assert.throws( + () => deprecate('still compliant', false, enabledOptions('not-excepted')), + /declared compliance/ + ); + } + + ['@test a passing test bypasses compliance throwing'](assert) { + setDeprecationStagesConfig({ compliance: '6.1.0' }); + + deprecate('test is true, deprecation not triggered', true, enabledOptions('passing')); + assert.ok(true, 'no throw when test passes'); + } + + ['@test compliance newer than installed ember-source is rejected']() { + expectAssertion(() => { + setDeprecationStagesConfig({ compliance: '9999.0.0' }); + }, /newer than the installed ember-source/); + } + + ['@test compliance may equal the installed ember-source version'](assert) { + setDeprecationStagesConfig({ compliance: VERSION.replace(/[-+].*$/, '') }); + assert.ok(true, 'current version accepted'); + } + + ['@test malformed config is rejected']() { + expectAssertion(() => { + setDeprecationStagesConfig({ enable: 'not-an-array' }); + }, /DEPRECATION_STAGES.enable/); + + expectAssertion(() => { + setDeprecationStagesConfig({ compliance: { 'ember-source': 'not-a-version' } }); + }, /must be a version string/); + + expectAssertion(() => { + setDeprecationStagesConfig({ assert: 'not-an-array' }); + }, /DEPRECATION_STAGES.assert/); + + expectAssertion(() => { + setDeprecationStagesConfig({ except: [42] }); + }, /DEPRECATION_STAGES.except/); + } + } +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25ffa279b3e..8f52b18dd9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -646,6 +646,9 @@ importers: '@ember/utils': specifier: workspace:* version: link:../utils + '@ember/version': + specifier: workspace:* + version: link:../version '@glimmer/destroyable': specifier: workspace:* version: link:../../@glimmer/destroyable diff --git a/testem.cjs b/testem.cjs index 201511b862a..33ecbc3eaf1 100644 --- a/testem.cjs +++ b/testem.cjs @@ -11,6 +11,15 @@ const variants = [ // hit its "until" version, the tests for it will behave correctly. 'OVERRIDE_DEPRECATION_VERSION', + // Comma-separated deprecation ids (or "true" for all) to enable early via + // EmberENV.DEPRECATION_STAGES.enable, so available-stage deprecations can be + // exercised per-id before they reach their "enabled" version. + 'ENABLED_DEPRECATIONS', + + // A version passed to EmberENV.DEPRECATION_STAGES.compliance: deprecations + // enabled at or before this ember-source version throw instead of warning. + 'DEPRECATION_COMPLIANCE', + // This enables all canary feature flags for unreleased feature within Ember // itself. 'ENABLE_OPTIONAL_FEATURES', diff --git a/tests/docs/expected.cjs b/tests/docs/expected.cjs index db1994ff30a..f7730093cc6 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -1,6 +1,7 @@ module.exports = { classitems: [ 'A', + 'DEPRECATION_STAGES', 'EXTEND_PROTOTYPES', 'GUID_KEY', 'GUID_PREFIX', From b27e347524913c0327ca565eecf14741e7c2c7a2 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:28:51 -0700 Subject: [PATCH 03/24] Link shakable deprecations to @ember/deprecated-features flags Revives @ember/deprecated-features as the per-deprecation flag source: one boolean const per shakable deprecation, named after its DEPRECATIONS registry key. deprecation() takes the flag as a second argument; a false flag makes the entry report isRemoved so unguarded reaches throw. The Comparable mixin body and the deprecated service inject body are guarded following the convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../@ember/-internals/deprecations/index.ts | 75 ++++++++++++++----- .../deprecations/tests/index-test.js | 41 +++++++++- packages/@ember/-internals/package.json | 1 + .../runtime/lib/mixins/comparable.ts | 61 ++++++++------- packages/@ember/deprecated-features/index.ts | 19 ++++- packages/@ember/service/index.ts | 5 +- packages/@ember/service/package.json | 1 + pnpm-lock.yaml | 6 ++ 8 files changed, 155 insertions(+), 54 deletions(-) diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index f624218376c..1725b5939d6 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -3,6 +3,7 @@ import { isDeprecationEnabledByConfig } from '@ember/debug/lib/deprecation-stage import { ENV } from '@ember/-internals/environment/lib/env'; import { VERSION } from '@ember/version'; import { deprecate, assert } from '@ember/debug'; +import { DEPRECATE_COMPARABLE_MIXIN, DEPRECATE_IMPORT_INJECT } from '@ember/deprecated-features'; import { dasherize } from '../string/index'; function isEnabled(options: DeprecationOptions) { @@ -35,17 +36,22 @@ interface DeprecationObject { // Getters rather than snapshots: registry entries are created at module // eval, but stage configuration can change afterwards (e.g. test harnesses // calling setDeprecationStagesConfig). -export function deprecation(options: DeprecationOptions): DeprecationObject { +// +// `flag` links a shakable deprecation to its @ember/deprecated-features +// constant: in a build where the flag is false the guarded implementation is +// gone, so the deprecation reports itself as removed and unguarded reaches +// throw via deprecateUntil. +export function deprecation(options: DeprecationOptions, flag?: boolean): DeprecationObject { return { options, get test() { return !isEnabled(options); }, get isEnabled() { - return isEnabled(options) || isRemoved(options); + return isEnabled(options) || isRemoved(options) || flag === false; }, get isRemoved() { - return isRemoved(options); + return isRemoved(options) || flag === false; }, }; } @@ -103,6 +109,31 @@ export function deprecation(options: DeprecationOptions): DeprecationObject { When adding a deprecation, we need to guard all the code that will eventually be removed, including tests. For tests that are not specifically testing the deprecated feature, we need to figure out how to test the behavior without encountering the deprecated feature, just as users would. + + ## Shakable deprecations + + A deprecation whose implementation carries real code weight should also be + *shakable*: add an `export const MY_DEPRECATION = true` to + `@ember/deprecated-features` (same name as the registry key), pass it as the + second argument to `deprecation()`, and guard the deprecated code path with + it: + + ```ts + import { MY_DEPRECATION } from '@ember/deprecated-features'; + + if (MY_DEPRECATION) { + // deprecated path, including the deprecateUntil call + } else { + // post-removal behavior + } + ``` + + Rules: reference the imported const directly (no destructuring, renaming, or + property access — babel-plugin-debug-macros can only fold direct + references), keep the deprecateUntil call inside the guarded branch so it is + stripped with the code, and put the post-removal behavior in the other + branch. In a build where the flag is false, the registry entry reports + `isRemoved`, so any unguarded reach throws the removal error. */ export const DEPRECATIONS = { DEPRECATE_IMPORT_EMBER(importName: string) { @@ -116,23 +147,29 @@ export const DEPRECATIONS = { ).toLowerCase()}-from-ember`, }); }, - DEPRECATE_IMPORT_INJECT: deprecation({ - for: 'ember-source', - id: 'importing-inject-from-ember-service', - since: { - available: '6.2.0', - enabled: '6.3.0', + DEPRECATE_IMPORT_INJECT: deprecation( + { + for: 'ember-source', + id: 'importing-inject-from-ember-service', + since: { + available: '6.2.0', + enabled: '6.3.0', + }, + until: '7.0.0', + url: 'https://deprecations.emberjs.com/id/importing-inject-from-ember-service', + }, + DEPRECATE_IMPORT_INJECT + ), + DEPRECATE_COMPARABLE_MIXIN: deprecation( + { + for: 'ember-source', + id: 'deprecate-comparable-mixin', + since: { available: '7.2.0', enabled: '7.2.0' }, + until: '7.5.0', + url: 'https://deprecations.emberjs.com/id/deprecate-comparable-mixin', }, - until: '7.0.0', - url: 'https://deprecations.emberjs.com/id/importing-inject-from-ember-service', - }), - DEPRECATE_COMPARABLE_MIXIN: deprecation({ - for: 'ember-source', - id: 'deprecate-comparable-mixin', - since: { available: '7.2.0', enabled: '7.2.0' }, - until: '7.5.0', - url: 'https://deprecations.emberjs.com/id/deprecate-comparable-mixin', - }), + DEPRECATE_COMPARABLE_MIXIN + ), }; export function deprecateUntil(message: string, deprecation: DeprecationObject) { diff --git a/packages/@ember/-internals/deprecations/tests/index-test.js b/packages/@ember/-internals/deprecations/tests/index-test.js index f4d97b59c8d..0deb462bb3b 100644 --- a/packages/@ember/-internals/deprecations/tests/index-test.js +++ b/packages/@ember/-internals/deprecations/tests/index-test.js @@ -1,7 +1,8 @@ import { AbstractTestCase, moduleFor } from 'internal-test-helpers'; -import { deprecation, deprecateUntil, isRemoved, emberVersionGte } from '../index'; +import { DEPRECATIONS, deprecation, deprecateUntil, isRemoved, emberVersionGte } from '../index'; import { ENV } from '@ember/-internals/environment'; import { setDeprecationStagesConfig } from '@ember/debug'; +import * as DEPRECATED_FEATURES from '@ember/deprecated-features'; let originalEnvValue; @@ -19,6 +20,44 @@ moduleFor( ENV.RAISE_ON_DEPRECATION = originalEnvValue; } + ['@test every @ember/deprecated-features flag matches a DEPRECATIONS registry key'](assert) { + let flagNames = Object.keys(DEPRECATED_FEATURES); + assert.notStrictEqual(flagNames.length, 0, 'flags exist'); + + for (let flagName of flagNames) { + assert.true( + flagName in DEPRECATIONS, + `${flagName} has a matching DEPRECATIONS registry entry` + ); + assert.strictEqual( + // eslint-disable-next-line import/namespace -- iterating the namespace's own keys + typeof DEPRECATED_FEATURES[flagName], + 'boolean', + `${flagName} is a boolean` + ); + } + } + + ['@test a deprecation whose flag is false reports itself as removed'](assert) { + let options = { + id: 'test-flagged-off', + until: '30.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-flagged-off', + since: { available: '1.0.0', enabled: '1.0.0' }, + }; + + assert.false(deprecation(options, true).isRemoved, 'flag true: not removed'); + assert.false(deprecation(options).isRemoved, 'no flag: not removed'); + assert.true(deprecation(options, false).isRemoved, 'flag false: removed'); + + assert.throws( + () => deprecateUntil('Shaken API reached', deprecation(options, false)), + /was removed in ember-source 30\.0\.0/, + 'deprecateUntil throws for a flagged-off deprecation' + ); + } + ['@test available-stage deprecations reflect stage config changes'](assert) { let AVAILABLE_DEPRECATION = deprecation({ id: 'test-available-stage', diff --git a/packages/@ember/-internals/package.json b/packages/@ember/-internals/package.json index ed45dfe6c74..2374d52e852 100644 --- a/packages/@ember/-internals/package.json +++ b/packages/@ember/-internals/package.json @@ -30,6 +30,7 @@ "@ember/component": "workspace:^", "@ember/controller": "workspace:*", "@ember/debug": "workspace:*", + "@ember/deprecated-features": "workspace:*", "@ember/destroyable": "workspace:*", "@ember/engine": "workspace:*", "@ember/enumerable": "workspace:*", diff --git a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts index e0b10e6eada..507a4e810fe 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts @@ -1,5 +1,6 @@ import Mixin from '@ember/object/mixin'; import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; +import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; /** @module ember @@ -19,34 +20,36 @@ import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; interface Comparable { compare: ((a: unknown, b: unknown) => -1 | 0 | 1) | null; } -const Comparable = Mixin.create({ - /** - __Required.__ You must implement this method to apply this mixin. - - Override to return the result of the comparison of the two parameters. The - compare method should return: - - - `-1` if `a < b` - - `0` if `a == b` - - `1` if `a > b` - - Default implementation raises an exception. - - @method compare - @param a {Object} the first object to compare - @param b {Object} the second object to compare - @return {Number} the result of the comparison - @private - */ - init() { - this._super(...arguments); - deprecateUntil( - 'The `Comparable` mixin is deprecated. Implement a `compare` method directly on your class instead.', - DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN - ); - }, - - compare: null, -}); +const Comparable = DEPRECATE_COMPARABLE_MIXIN + ? Mixin.create({ + /** + __Required.__ You must implement this method to apply this mixin. + + Override to return the result of the comparison of the two parameters. The + compare method should return: + + - `-1` if `a < b` + - `0` if `a == b` + - `1` if `a > b` + + Default implementation raises an exception. + + @method compare + @param a {Object} the first object to compare + @param b {Object} the second object to compare + @return {Number} the result of the comparison + @private + */ + init() { + this._super(...arguments); + deprecateUntil( + 'The `Comparable` mixin is deprecated. Implement a `compare` method directly on your class instead.', + DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN + ); + }, + + compare: null, + }) + : undefined; export default Comparable; diff --git a/packages/@ember/deprecated-features/index.ts b/packages/@ember/deprecated-features/index.ts index ed08aa1ff85..8ca0dc3109d 100644 --- a/packages/@ember/deprecated-features/index.ts +++ b/packages/@ember/deprecated-features/index.ts @@ -1,5 +1,16 @@ -// These versions should be the version that the deprecation was _introduced_, -// not the version that the feature will be removed. +// One flag per shakable deprecation, named identically to its entry in the +// DEPRECATIONS registry (@ember/-internals/deprecations). All flags are true +// in the standard build; a shaken build sets flags to false, which both +// strips the guarded legacy code paths and makes the deprecation report +// itself as removed at runtime (so unguarded reaches throw). +// +// Guard convention: reference the imported const directly (no destructuring, +// renaming, or property access — babel-plugin-debug-macros can only fold +// direct references), keep the deprecation call inside the guarded branch, +// and put the post-removal behavior in the other branch. -/** Introduced in 4.0.0-beta.1 */ -export const ASSIGN = true; +/** id: deprecate-comparable-mixin, since: 7.2.0/7.2.0, until: 7.5.0 */ +export const DEPRECATE_COMPARABLE_MIXIN = true; + +/** id: importing-inject-from-ember-service, since: 6.2.0/6.3.0, until: 7.0.0 */ +export const DEPRECATE_IMPORT_INJECT = true; diff --git a/packages/@ember/service/index.ts b/packages/@ember/service/index.ts index 85f69d04b12..5b48c791578 100644 --- a/packages/@ember/service/index.ts +++ b/packages/@ember/service/index.ts @@ -1,5 +1,6 @@ import { FrameworkObject } from '@ember/object/-internals'; import { DEPRECATIONS, deprecateUntil } from '@ember/-internals/deprecations'; +import { DEPRECATE_IMPORT_INJECT } from '@ember/deprecated-features'; import type { DecoratorPropertyDescriptor, ElementDescriptor, @@ -34,7 +35,9 @@ export function inject( DEPRECATIONS.DEPRECATE_IMPORT_INJECT ); - return metalInject('service', ...args); + if (DEPRECATE_IMPORT_INJECT) { + return metalInject('service', ...args); + } } /** diff --git a/packages/@ember/service/package.json b/packages/@ember/service/package.json index 4af5a789601..738c935a978 100644 --- a/packages/@ember/service/package.json +++ b/packages/@ember/service/package.json @@ -10,6 +10,7 @@ "@ember/-internals": "workspace:*", "@ember/array": "workspace:*", "@ember/debug": "workspace:*", + "@ember/deprecated-features": "workspace:*", "@ember/object": "workspace:*", "@glimmer/destroyable": "workspace:*", "@glimmer/env": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f52b18dd9a..3aca0eb7c91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@ember/debug': specifier: workspace:* version: link:../debug + '@ember/deprecated-features': + specifier: workspace:* + version: link:../deprecated-features '@ember/destroyable': specifier: workspace:* version: link:../destroyable @@ -1087,6 +1090,9 @@ importers: '@ember/debug': specifier: workspace:* version: link:../debug + '@ember/deprecated-features': + specifier: workspace:* + version: link:../deprecated-features '@ember/object': specifier: workspace:* version: link:../object From 2655f9e8efaf6d1878e665d9357d9c9971af5a89 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:39:40 -0700 Subject: [PATCH 04/24] Wire deprecation shaking into the dist build The standard dist externalizes @ember/deprecated-features to a package self-reference so the flags stay live for app-side shaking, emits the flags module and dist/deprecation-flags.json. EMBER_DEPRECATION_FLAGS builds dist/deprecation-custom/{dev,prod} with the flags compile-time folded and guarded code eliminated. bin/assert-deprecations-shaken.mjs verifies both directions; a new CI job runs it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 20 ++ bin/assert-deprecations-shaken.mjs | 182 ++++++++++++++++++ broccoli/deprecated-features.cjs | 85 ++++++++ package.json | 1 + rollup.config.mjs | 91 ++++++++- .../deprecated-features-manifest-test.cjs | 54 ++++++ 6 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 bin/assert-deprecations-shaken.mjs create mode 100644 broccoli/deprecated-features.cjs create mode 100644 tests/node/deprecated-features-manifest-test.cjs diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index b8556dce43b..262e2077b08 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -216,6 +216,26 @@ jobs: run: | ${MATRIX_COMMAND} + deprecation-shaken-dist: + name: Deprecation-shaken dist + runs-on: ubuntu-latest + needs: [basic-test, lint, types] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: build (standard + shaken variant) + env: + EMBER_DEPRECATION_FLAGS: "all=false" + run: pnpm build:js + - name: assert shaken dist is clean and standard dist stays live + env: + EMBER_DEPRECATION_FLAGS: "all=false" + run: node bin/assert-deprecations-shaken.mjs + - name: dist size report + run: du -sh dist/dev dist/prod dist/deprecation-custom/dev dist/deprecation-custom/prod + node-test: name: Node.js Tests runs-on: ubuntu-latest diff --git a/bin/assert-deprecations-shaken.mjs b/bin/assert-deprecations-shaken.mjs new file mode 100644 index 00000000000..3258b6371d6 --- /dev/null +++ b/bin/assert-deprecations-shaken.mjs @@ -0,0 +1,182 @@ +/* eslint-disable no-console */ +/* + Verifies deprecation shaking end-to-end: + + 1. dist/deprecation-custom/prod (built with EMBER_DEPRECATION_FLAGS + disabling flags) must not contain the disabled flag identifiers nor the + per-flag content markers — proof the guarded code paths were eliminated. + 2. dist/prod (the standard build) must keep the flags live: the flags + module exists with every const `true`, consumers import it via the + package self-reference, the identifiers and content markers are present, + and dist/deprecation-flags.json matches the manifest. + + Run with --report to print findings without failing. +*/ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { FLAGS, parseFlagsFromEnv, DEFAULT_FLAGS } = require('../broccoli/deprecated-features.cjs'); + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const report = process.argv.includes('--report'); + +// Content markers are runtime strings inside a guarded branch (never assert +// or deprecate-call text, which the prod build strips, and never words that +// appear in doc comments — comments are stripped before matching but only +// block comments reliably). +const CONTENT_MARKERS = { + DEPRECATE_COMPARABLE_MIXIN: ['The `Comparable` mixin is deprecated'], + // DEPRECATE_IMPORT_INJECT has no content marker: its deprecateUntil message + // intentionally survives shaking as the throwing stub. The flag identifier + // check still proves the guarded implementation was folded away. + DEPRECATE_IMPORT_INJECT: [], +}; + +const FLAGS_MODULE_SUFFIX = 'packages/@ember/deprecated-features/index.js'; +const SELF_REFERENCE = 'ember-source/@ember/deprecated-features/index.js'; + +let failures = []; + +function stripComments(code) { + return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +function walkJs(dir, found = []) { + for (let entry of readdirSync(dir, { withFileTypes: true })) { + let full = join(dir, entry.name); + if (entry.isDirectory()) { + walkJs(full, found); + } else if (entry.name.endsWith('.js')) { + found.push(full); + } + } + return found; +} + +function checkShakenDist(disabledFlags) { + let distDir = join(projectRoot, 'dist/deprecation-custom/prod'); + if (!existsSync(distDir)) { + failures.push(`missing ${distDir} — build with EMBER_DEPRECATION_FLAGS first`); + return; + } + + for (let file of walkJs(distDir)) { + if (file.endsWith(FLAGS_MODULE_SUFFIX)) { + let code = readFileSync(file, 'utf8'); + for (let flag of disabledFlags) { + if (!new RegExp(`${flag}\\s*=\\s*false`).test(code)) { + failures.push(`${file}: expected ${flag} = false in shaken flags module`); + } + } + continue; + } + + let code = stripComments(readFileSync(file, 'utf8')); + for (let flag of disabledFlags) { + // A flag name may legitimately survive as a DEPRECATIONS registry key + // (`DEPRECATE_X:`) or property access (`DEPRECATIONS.DEPRECATE_X`) — + // those are the runtime registry, not the folded import. Only a + // standalone binding reference means folding failed. + if (new RegExp(`(? ({ + const: name, + id, + since, + until, + })); + if (JSON.stringify(meta) !== JSON.stringify(expected)) { + failures.push(`${metaPath} does not match broccoli/deprecated-features.cjs FLAGS manifest`); + } + } +} + +let disabledFlags = Object.keys(DEFAULT_FLAGS); +if (process.env.EMBER_DEPRECATION_FLAGS) { + let resolved = parseFlagsFromEnv(process.env.EMBER_DEPRECATION_FLAGS); + disabledFlags = Object.keys(resolved).filter((name) => resolved[name] === false); +} + +checkShakenDist(disabledFlags); +checkStandardDist(); + +if (failures.length > 0) { + console.log(`assert-deprecations-shaken: ${failures.length} problem(s):`); + for (let failure of failures) { + console.log(` - ${failure}`); + } + if (!report) { + throw new Error(`assert-deprecations-shaken found ${failures.length} problem(s)`); + } +} else { + console.log( + `assert-deprecations-shaken: OK (${disabledFlags.length} flag(s) verified shaken; standard dist verified live)` + ); +} diff --git a/broccoli/deprecated-features.cjs b/broccoli/deprecated-features.cjs new file mode 100644 index 00000000000..fb56746b538 --- /dev/null +++ b/broccoli/deprecated-features.cjs @@ -0,0 +1,85 @@ +'use strict'; + +// Canonical build-time manifest of shakable deprecations. Each key must match +// both an `export const = true` in packages/@ember/deprecated-features +// and a DEPRECATIONS registry key in @ember/-internals/deprecations (a +// conformance test enforces the latter pairing). +const FLAGS = Object.freeze({ + DEPRECATE_COMPARABLE_MIXIN: Object.freeze({ + id: 'deprecate-comparable-mixin', + since: Object.freeze({ available: '7.2.0', enabled: '7.2.0' }), + until: '7.5.0', + }), + DEPRECATE_IMPORT_INJECT: Object.freeze({ + id: 'importing-inject-from-ember-service', + since: Object.freeze({ available: '6.2.0', enabled: '6.3.0' }), + until: '7.0.0', + }), +}); + +const DEFAULT_FLAGS = Object.freeze( + Object.fromEntries(Object.keys(FLAGS).map((name) => [name, true])) +); + +function resolveFlags(overrides = {}) { + for (let [name, value] of Object.entries(overrides)) { + if (!(name in DEFAULT_FLAGS)) { + throw new Error( + `Unknown deprecation flag: ${name}. Valid flags: ${Object.keys(DEFAULT_FLAGS).join(', ')}` + ); + } + if (typeof value !== 'boolean') { + throw new Error(`Deprecation flag ${name} must be a boolean, got: ${value}`); + } + } + return { ...DEFAULT_FLAGS, ...overrides }; +} + +// Parses EMBER_DEPRECATION_FLAGS, e.g. +// "DEPRECATE_COMPARABLE_MIXIN=false,DEPRECATE_IMPORT_INJECT=false", with +// "all=false" as shorthand for disabling every flag. +function parseFlagsFromEnv(value) { + let overrides = {}; + for (let entry of value.split(',')) { + let trimmed = entry.trim(); + if (trimmed === '') continue; + let match = /^(\w+)=(true|false)$/.exec(trimmed); + if (!match) { + throw new Error( + `Cannot parse EMBER_DEPRECATION_FLAGS entry: "${trimmed}" (expected NAME=true or NAME=false)` + ); + } + if (match[1] === 'all') { + for (let name of Object.keys(DEFAULT_FLAGS)) { + overrides[name] = match[2] === 'true'; + } + } else { + overrides[match[1]] = match[2] === 'true'; + } + } + return resolveFlags(overrides); +} + +// babel-plugin-debug-macros tuple that folds @ember/deprecated-features +// imports to boolean literals. Only used for shaken variant builds; the +// standard dist keeps the imports live (externalized) so apps can shake. +function deprecatedFeatures(flags = DEFAULT_FLAGS) { + return [ + require.resolve('babel-plugin-debug-macros'), + { + flags: [ + { + source: '@ember/deprecated-features', + flags: { ...flags }, + }, + ], + }, + 'debug-macros:deprecated-features', + ]; +} + +module.exports = deprecatedFeatures; +module.exports.FLAGS = FLAGS; +module.exports.DEFAULT_FLAGS = DEFAULT_FLAGS; +module.exports.resolveFlags = resolveFlags; +module.exports.parseFlagsFromEnv = parseFlagsFromEnv; diff --git a/package.json b/package.json index 83ba3b57cab..7cbf960b94a 100644 --- a/package.json +++ b/package.json @@ -229,6 +229,7 @@ "@ember/debug/lib/assert.js": "ember-source/@ember/debug/lib/assert.js", "@ember/debug/lib/capture-render-tree.js": "ember-source/@ember/debug/lib/capture-render-tree.js", "@ember/debug/lib/deprecate.js": "ember-source/@ember/debug/lib/deprecate.js", + "@ember/debug/lib/deprecation-stages.js": "ember-source/@ember/debug/lib/deprecation-stages.js", "@ember/debug/lib/handlers.js": "ember-source/@ember/debug/lib/handlers.js", "@ember/debug/lib/inspect.js": "ember-source/@ember/debug/lib/inspect.js", "@ember/debug/lib/testing.js": "ember-source/@ember/debug/lib/testing.js", diff --git a/rollup.config.mjs b/rollup.config.mjs index 37333ab65f9..6ca30b5a8b9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -14,6 +14,7 @@ const projectRoot = dirname(fileURLToPath(import.meta.url)); const packageCache = PackageCache.shared('ember-source', projectRoot); const buildDebugMacroPlugin = require('./broccoli/build-debug-macro-plugin.cjs'); const canaryFeatures = require('./broccoli/canary-features.cjs'); +const deprecatedFeatures = require('./broccoli/deprecated-features.cjs'); const testDependencies = [ 'qunit', @@ -32,6 +33,19 @@ let configs = [ glimmerSyntaxCJS(), ]; +// A deprecation-shaken variant: EMBER_DEPRECATION_FLAGS="DEPRECATE_X=false,..." +// (or "all=false") builds dist/deprecation-custom/{dev,prod} with the flagged +// deprecations compile-time folded and their guarded code paths eliminated. +// CI uses this to prove each shakable deprecation actually leaves the bundle; +// apps normally shake instead via the ember-source/deprecation-shaking plugin. +if (process.env.EMBER_DEPRECATION_FLAGS) { + let flags = deprecatedFeatures.parseFlagsFromEnv(process.env.EMBER_DEPRECATION_FLAGS); + configs.push( + sharedESMConfig({ input: esmInputs(), debugMacrosMode: true, deprecationFlags: flags }), + sharedESMConfig({ input: esmInputs(), debugMacrosMode: false, deprecationFlags: flags }) + ); +} + if (process.env.DEBUG_SINGLE_CONFIG) { configs = configs.slice( parseInt(process.env.DEBUG_SINGLE_CONFIG), @@ -71,8 +85,9 @@ function esmInputs() { }; } -function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false }) { - let outputDir = debugMacrosMode === false ? 'dist/prod' : 'dist/dev'; +function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false, deprecationFlags }) { + let distRoot = deprecationFlags ? 'dist/deprecation-custom' : 'dist'; + let outputDir = debugMacrosMode === false ? `${distRoot}/prod` : `${distRoot}/dev`; let babelConfig = { ...sharedBabelConfig }; babelConfig.plugins = [ ...babelConfig.plugins, @@ -80,6 +95,12 @@ function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false }) canaryFeatures(), ]; + if (deprecationFlags) { + // Shaken variant: fold the flags to literals so guarded deprecated code + // paths are dead-code-eliminated by rollup's treeshake. + babelConfig.plugins.push(deprecatedFeatures(deprecationFlags)); + } + let plugins = [ babel({ babelHelpers: 'bundled', @@ -89,12 +110,20 @@ function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false }) }), resolveTS(), version(), - resolvePackages({ ...exposedDependencies(), ...hiddenDependencies() }), + deprecationFlagsModule(deprecationFlags), + resolvePackages( + { ...exposedDependencies(), ...hiddenDependencies() }, + // The standard dist keeps @ember/deprecated-features live (externalized + // to a package self-reference) so apps can shake per-deprecation. In + // the shaken variant the imports are already folded away by babel. + { externalizeDeprecatedFeatures: !deprecationFlags } + ), pruneEmptyBundles(), ]; if (includePackageMeta) { plugins.push(packageMeta()); + plugins.push(emitDeprecationFlagsMeta()); } return { @@ -401,11 +430,12 @@ function resolveTS() { export function resolvePackages(deps, params) { const isExternal = params?.isExternal; const enableLocalDebug = params?.enableLocalDebug ?? false; + const externalizeDeprecatedFeatures = params?.externalizeDeprecatedFeatures ?? false; return { enforce: 'pre', name: 'resolve-packages', - async resolveId(source) { + async resolveId(source, importer) { if (source.startsWith('\0')) { return; } @@ -419,6 +449,16 @@ export function resolvePackages(deps, params) { return resolve(projectRoot, 'packages/@glimmer/local-debug-flags/disabled.ts'); } + // Keep the deprecation flags live in the published dist: consumers of + // the flags import a single shared module (a package self-reference + // that resolves through our own `exports` map), which the + // ember-source/deprecation-shaking app plugin can replace to shake + // deprecated code. Only imports are redirected; the module itself + // (importer === undefined) still builds as a normal entrypoint. + if (externalizeDeprecatedFeatures && importer && source === '@ember/deprecated-features') { + return { external: true, id: 'ember-source/@ember/deprecated-features/index.js' }; + } + let pkgName = packageName(source); if (pkgName) { // having a pkgName means this is not a relative import @@ -507,6 +547,49 @@ export function version() { }; } +// In a shaken variant build, rewrite the flags module itself so its exported +// constants match the variant's flag values (consumer imports are already +// folded by babel; this keeps the emitted module honest for anything that +// imports it at runtime). +function deprecationFlagsModule(deprecationFlags) { + return { + name: 'deprecation-flags-module', + load(id) { + if ( + deprecationFlags && + id[0] !== '\0' && + id.endsWith('packages/@ember/deprecated-features/index.ts') + ) { + return { + code: Object.entries(deprecationFlags) + .map(([name, value]) => `export const ${name} = ${value};\n`) + .join(''), + }; + } + }, + }; +} + +// Machine-readable description of the shakable deprecation flags, consumed by +// the ember-source/deprecation-shaking app plugin. +function emitDeprecationFlagsMeta() { + return { + name: 'deprecation-flags-meta', + generateBundle() { + let meta = Object.entries(deprecatedFeatures.FLAGS).map(([name, { id, since, until }]) => ({ + const: name, + id, + since, + until, + })); + writeFileSync( + resolve(projectRoot, 'dist/deprecation-flags.json'), + JSON.stringify(meta, null, 2) + '\n' + ); + }, + }; +} + function pruneEmptyBundles() { return { name: 'prune-empty-bundles', diff --git a/tests/node/deprecated-features-manifest-test.cjs b/tests/node/deprecated-features-manifest-test.cjs new file mode 100644 index 00000000000..4ad282053d1 --- /dev/null +++ b/tests/node/deprecated-features-manifest-test.cjs @@ -0,0 +1,54 @@ +'use strict'; + +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const { + FLAGS, + DEFAULT_FLAGS, + resolveFlags, + parseFlagsFromEnv, +} = require('../../broccoli/deprecated-features.cjs'); + +// The build-time manifest and the runtime flags package must describe the +// same set of deprecations (the browser conformance test ties the flags +// package to the DEPRECATIONS registry). +QUnit.module('deprecated-features manifest', function () { + QUnit.test('manifest keys match the @ember/deprecated-features exports', function (assert) { + let source = readFileSync( + join(__dirname, '../../packages/@ember/deprecated-features/index.ts'), + 'utf8' + ); + let exported = [...source.matchAll(/^export const (\w+) = (true|false);/gm)].map( + (match) => match[1] + ); + + assert.deepEqual(exported.sort(), Object.keys(FLAGS).sort()); + }); + + QUnit.test('resolveFlags validates names and values', function (assert) { + assert.deepEqual(resolveFlags(), DEFAULT_FLAGS); + assert.throws(() => resolveFlags({ NOT_A_FLAG: false }), /Unknown deprecation flag/); + assert.throws(() => resolveFlags({ DEPRECATE_COMPARABLE_MIXIN: 'false' }), /must be a boolean/); + }); + + QUnit.test('parseFlagsFromEnv parses entries and the all shorthand', function (assert) { + assert.deepEqual(parseFlagsFromEnv('DEPRECATE_COMPARABLE_MIXIN=false'), { + ...DEFAULT_FLAGS, + DEPRECATE_COMPARABLE_MIXIN: false, + }); + assert.deepEqual( + parseFlagsFromEnv('all=false'), + Object.fromEntries(Object.keys(DEFAULT_FLAGS).map((name) => [name, false])) + ); + assert.throws(() => parseFlagsFromEnv('DEPRECATE_COMPARABLE_MIXIN'), /Cannot parse/); + }); + + QUnit.test('manifest entries carry id, since, and until', function (assert) { + for (let [name, meta] of Object.entries(FLAGS)) { + assert.strictEqual(typeof meta.id, 'string', `${name} has an id`); + assert.strictEqual(typeof meta.until, 'string', `${name} has an until`); + assert.strictEqual(typeof meta.since.available, 'string', `${name} has since.available`); + } + }); +}); From e84cd53c38c0d554aa86a7679f19679567969924 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:46:43 -0700 Subject: [PATCH 05/24] Add ember-source/deprecation-shaking app plugin with smoke coverage A vite/rollup plugin that replaces the externalized flags module in ember-source's dist based on app config (compliantThrough, strip, keep): shaken deprecations lose their implementation to DCE and throw the removal error if reached. The smoke scenario builds a real Embroider vite app both ways and verifies bundle contents and runtime behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/deprecation-shaking/index.js | 89 ++++++++++++ package.json | 1 + .../scenarios/deprecation-shaking-test.ts | 128 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 lib/deprecation-shaking/index.js create mode 100644 smoke-tests/scenarios/deprecation-shaking-test.ts diff --git a/lib/deprecation-shaking/index.js b/lib/deprecation-shaking/index.js new file mode 100644 index 00000000000..4b9f5e44898 --- /dev/null +++ b/lib/deprecation-shaking/index.js @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const emberSourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const FLAGS_MODULE_SUFFIX = ['packages', '@ember', 'deprecated-features', 'index.js'].join(sep); + +// Numeric segment-wise comparison of dotted version strings (pre-release +// tags ignored), so multi-digit minors order correctly (3.28 > 3.4). +function versionLte(a, b) { + let aParts = a.split('.').map((part) => parseInt(part, 10)); + let bParts = b.split('.').map((part) => parseInt(part, 10)); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + let diff = (aParts[i] || 0) - (bParts[i] || 0); + if (diff !== 0) return diff < 0; + } + return true; +} + +/** + Vite/Rollup plugin that shakes deprecated code out of ember-source. + + Replaces ember-source's `@ember/deprecated-features` flags module so that + the selected deprecations are disabled: their guarded implementations are + dead-code-eliminated in production builds, and reaching a removed API + throws the same error it would after the deprecation's `until` release. + + ```js + // vite.config.mjs + import { deprecationShaking } from 'ember-source/deprecation-shaking'; + + export default defineConfig({ + plugins: [ + classicEmberSupport(), + ember(), + deprecationShaking({ + // shake everything with `until` at or below this ember-source version + compliantThrough: '7.5.0', + // and/or shake specific deprecation ids + strip: ['deprecate-comparable-mixin'], + // ids to keep even if compliantThrough covers them + keep: [], + }), + ], + }); + ``` +*/ +export function deprecationShaking({ compliantThrough, strip = [], keep = [] } = {}) { + let meta = JSON.parse(readFileSync(resolve(emberSourceRoot, 'dist/deprecation-flags.json'))); + let knownIds = new Set(meta.map((entry) => entry.id)); + + for (let id of [...strip, ...keep]) { + if (!knownIds.has(id)) { + throw new Error( + `deprecationShaking: unknown deprecation id "${id}". Known shakable ids: ${[ + ...knownIds, + ].join(', ')}` + ); + } + } + + let flags = Object.fromEntries( + meta.map(({ const: name, id, until }) => { + let shaken = + (strip.includes(id) || + (compliantThrough !== undefined && versionLte(until, compliantThrough))) && + !keep.includes(id); + return [name, !shaken]; + }) + ); + + let code = + Object.entries(flags) + .map(([name, value]) => `export const ${name} = ${value};`) + .join('\n') + '\n'; + + return { + name: 'ember-source-deprecation-shaking', + enforce: 'pre', + load(id) { + // Match the resolved flags module by path so this works whether the + // dist chunks import it via package self-reference or relative path. + if (id.split('?')[0].endsWith(FLAGS_MODULE_SUFFIX) && id.includes(sep + 'dist' + sep)) { + return code; + } + }, + }; +} diff --git a/package.json b/package.json index 7cbf960b94a..4e1bafef3db 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "ember-addon" ], "exports": { + "./deprecation-shaking": "./lib/deprecation-shaking/index.js", "./*": { "development": "./dist/dev/packages/*", "production": "./dist/prod/packages/*", diff --git a/smoke-tests/scenarios/deprecation-shaking-test.ts b/smoke-tests/scenarios/deprecation-shaking-test.ts new file mode 100644 index 00000000000..1a623e4b09c --- /dev/null +++ b/smoke-tests/scenarios/deprecation-shaking-test.ts @@ -0,0 +1,128 @@ +import { Project, Scenarios } from 'scenario-tester'; +import type { PreparedApp } from 'scenario-tester'; +import { dirname, join } from 'node:path'; +import { readdirSync, readFileSync } from 'node:fs'; +import * as QUnit from 'qunit'; +const { module: Qmodule, test } = QUnit; + +// A runtime string inside the shaken branch of the Comparable mixin — present +// in a normal build, gone from a shaken one. +const MARKER = 'The `Comparable` mixin is deprecated'; + +function distContains(appDir: string, text: string): boolean { + let queue = [join(appDir, 'dist')]; + while (queue.length > 0) { + let dir = queue.pop()!; + for (let entry of readdirSync(dir, { withFileTypes: true })) { + let full = join(dir, entry.name); + if (entry.isDirectory()) { + queue.push(full); + } else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(text)) { + return true; + } + } + } + return false; +} + +Scenarios.fromProject(() => + Project.fromDir(dirname(require.resolve('../v2-app-template/package.json')), { + linkDevDeps: true, + }) +) + .map('deprecation-shaking', (project) => { + project.mergeFiles({ + // The plugin is applied only when SHAKE=1 so the same app can build + // both ways. + 'vite.config.mjs': ` + import { defineConfig } from 'vite'; + import { extensions, classicEmberSupport, ember } from '@embroider/vite'; + import { babel } from '@rollup/plugin-babel'; + import { deprecationShaking } from 'ember-source/deprecation-shaking'; + + export default defineConfig({ + // the app template has no terser; rollup's DCE is what shaking + // relies on, so minification is irrelevant here + build: { minify: false }, + plugins: [ + classicEmberSupport(), + ember(), + ...(process.env.SHAKE + ? [ + deprecationShaking({ + strip: ['deprecate-comparable-mixin', 'importing-inject-from-ember-service'], + }), + ] + : []), + babel({ + babelHelpers: 'runtime', + extensions, + }), + ], + }); + `, + tests: { + integration: { + 'deprecation-shaking-test.js': ` + import { module, test } from 'qunit'; + import Comparable from '@ember/-internals/runtime/lib/mixins/comparable'; + import { DEPRECATIONS } from '@ember/-internals/deprecations'; + import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; + import { inject } from '@ember/service'; + + module('deprecation shaking', function () { + // Passes in both the shaken and unshaken build: the runtime + // must agree with the flag either way. + test('flag state is consistent with runtime behavior', function (assert) { + if (DEPRECATE_COMPARABLE_MIXIN) { + assert.true(Boolean(Comparable), 'Comparable mixin exists while the flag is on'); + assert.false( + DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN.isRemoved, + 'not removed while the flag is on' + ); + } else { + assert.strictEqual(Comparable, undefined, 'Comparable mixin is shaken away'); + assert.true( + DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN.isRemoved, + 'reports removed when the flag is off' + ); + } + }); + + test('an API past its until version throws the removal error', function (assert) { + assert.throws(() => inject('foo'), /was removed in ember-source/); + }); + }); + `, + }, + }, + }); + }) + .forEachScenario((scenario) => { + Qmodule(scenario.name, function (hooks) { + let app: PreparedApp; + hooks.before(async () => { + app = await scenario.prepare(); + }); + + // Control: without the plugin the deprecated code ships (also proves + // the MARKER stays a valid probe). + test('unshaken build contains the deprecated code and tests pass', async function (assert) { + let result = await app.execute('pnpm test'); + assert.equal(result.exitCode, 0, result.output); + assert.true(distContains(app.dir, MARKER), 'marker present in unshaken build'); + }); + + test('shaken build drops the deprecated code and tests pass', async function (assert) { + let result = await app.execute('pnpm test', { env: { SHAKE: '1' } }); + assert.equal(result.exitCode, 0, result.output); + assert.false(distContains(app.dir, MARKER), 'marker absent from shaken build'); + }); + + test('shaken production build also drops the deprecated code', async function (assert) { + let result = await app.execute('pnpm build', { env: { SHAKE: '1' } }); + assert.equal(result.exitCode, 0, result.output); + assert.false(distContains(app.dir, MARKER), 'marker absent from shaken prod build'); + }); + }); + }); From 39a0ef6b665219209f85b71ccd45c5d7dccd58c5 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:49:08 -0700 Subject: [PATCH 06/24] Refine RFC and guard-convention docs for the two guard shapes Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eprecation-early-enablement-and-shaking.md | 23 +++++++++++++++++-- .../@ember/-internals/deprecations/index.ts | 12 ++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index fa5ba0733bf..799dbcc04bd 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -159,8 +159,10 @@ internal `DEPRECATIONS` registry: export const DEPRECATE_COMPARABLE_MIXIN = true; ``` -Deprecated code paths are guarded by the flag, with the deprecation call -inside the guard and the post-removal behavior in the other branch: +Deprecated code paths are guarded by the flag. When the deprecated thing has +a post-removal replacement shape, the deprecation call sits inside the guard +(it is stripped with the code) and the other branch holds the post-removal +behavior: ```ts import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; @@ -176,6 +178,20 @@ const Comparable = DEPRECATE_COMPARABLE_MIXIN : undefined; // post-removal shape ``` +When the deprecated thing is itself an entrypoint (a deprecated function or +import with no replacement shape), the `deprecateUntil` call instead sits +*before* the guard: it survives shaking as the throwing stub while the +guarded implementation is eliminated: + +```ts +export function inject(...args) { + deprecateUntil(msg, DEPRECATIONS.DEPRECATE_IMPORT_INJECT); // throws when shaken + if (DEPRECATE_IMPORT_INJECT) { + return metalInject('service', ...args); + } +} +``` + The registry entry is linked to the flag, so when the flag is `false` the deprecation reports itself as *removed*: any unguarded reach of the API throws the same "has been removed" error that shipping past `until` would @@ -274,3 +290,6 @@ deprecations of substantial subsystems should be. none? Deferred. - Glimmer VM deprecations use their own override table upstream; wiring them into this system is future work. +- A shaken export read as a value (e.g. the `Comparable` mixin) becomes + `undefined` rather than a build error. True removal at a major would fail + the build instead. Is a lint rule or resolver-level error worth providing? diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 1725b5939d6..36288256106 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -130,10 +130,14 @@ export function deprecation(options: DeprecationOptions, flag?: boolean): Deprec Rules: reference the imported const directly (no destructuring, renaming, or property access — babel-plugin-debug-macros can only fold direct - references), keep the deprecateUntil call inside the guarded branch so it is - stripped with the code, and put the post-removal behavior in the other - branch. In a build where the flag is false, the registry entry reports - `isRemoved`, so any unguarded reach throws the removal error. + references). When the deprecated code has a post-removal shape, keep the + deprecateUntil call inside the guarded branch (it is stripped with the code) + and put the post-removal behavior in the other branch. When the deprecated + thing is itself an entrypoint (like the deprecated `inject` function), put + the deprecateUntil call before the guard instead — it survives shaking as + the throwing stub while the guarded implementation is eliminated. In a build + where the flag is false, the registry entry reports `isRemoved`, so any + reach of the API throws the removal error. */ export const DEPRECATIONS = { DEPRECATE_IMPORT_EMBER(importName: string) { From e129152b1f23b22571645a80ba32b5098916dadd Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 14:15:06 -0700 Subject: [PATCH 07/24] Discuss @embroider/macros in the RFC alternatives Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eprecation-early-enablement-and-shaking.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index 799dbcc04bd..af3c9c74bf0 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -271,6 +271,27 @@ deprecations of substantial subsystems should be. - **Publish-time folding only** (the original svelte plan via ember-cli-babel): predates prebuilt ESM dists; apps no longer re-transpile ember-source, so publish-time folding gives apps no control at all. +- **`@embroider/macros`** (`getGlobalConfig` + `macroCondition`, configured + via `setConfig`): the ecosystem-standard tool for app-configured build-time + conditionals, and how ember-data expressed its deprecation flags for years. + It was not chosen here because: + - ember-source's published dist would gain a runtime import of + `@embroider/macros` (today it is dependency-free plain ESM, which + matters for consumers like the node-side template compiler and any + non-Ember tooling that imports dist modules directly). + - Folding only happens inside Embroider pipelines with static config; + every other consumer needs the macros runtime just to boot, and the + compile-only `macroCondition` form breaks plain-module consumption + outright. + - The externalized-flags-module approach is bundler-agnostic: any + vite/rollup pipeline can shake, Embroider or not. + + Notably, warp-drive arrived at the same conclusion: it moved off + `@embroider/macros` to `@warp-drive/build-config`, whose architecture — an + externalized flags module in the published dist plus an app-side build + transform assigning the values — is structurally what this RFC proposes. + A future integration could still layer `setConfig`-style configuration on + top as sugar over the same flags module. - **Handler-based compliance** (build throwing on top of `registerDeprecationHandler`): works for warnings but cannot make the *removed* semantics (throw even in paths that suppress warnings) or feed From 1aa3d1e49d0aff7fdcddea777f98b7e4f712acfd Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 14:21:19 -0700 Subject: [PATCH 08/24] Resolve RFC open questions on API privacy and factory-family flags setDeprecationStagesConfig stays private with a follow-up-RFC path; dynamic-id deprecation families take one flag, with the past-until import-from-ember family deliberately unflagged. The compliance-scope and shaken-undefined questions stay open with stated defaults. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eprecation-early-enablement-and-shaking.md | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index af3c9c74bf0..2ce841d6afa 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -143,7 +143,11 @@ for triaging warnings; compliance is the tool for locking in finished migrations. A private `setDeprecationStagesConfig()` API allows test harnesses to swap -configuration at runtime; it is not (yet) public API. +configuration at runtime. It stays private in this RFC: the eventual consumer +is a test-helpers/ember-qunit integration ("run this module with deprecation +X enabled"), and blessing a public shape before that integration exists would +lock in an API nobody has used. A follow-up RFC can promote it once the shape +has proven out — the same path `registerDeprecationHandler` took. ### Part 2: Deprecation shaking @@ -241,6 +245,14 @@ CI. Not every deprecation must be shakable — tiny ones with no meaningful implementation weight can remain plain runtime deprecations — but deprecations of substantial subsystems should be. +Deprecations with *dynamic* ids (registry factories like +`DEPRECATE_IMPORT_EMBER`, which mints one id per legacy import name) take a +single flag for the whole family: shaking is a statement about the guarded +implementation, and the family shares one. The existing +`deprecate-import-*-from-ember` family itself is deliberately not flagged — +it is already past its `until` version, so its entire surface throws today +and is deleted at the next major regardless. + ## How we teach this - Each deprecation guide entry gains an "early opt-in" snippet @@ -302,15 +314,21 @@ deprecations of substantial subsystems should be. ## Unresolved questions -- Should `setDeprecationStagesConfig` become public API for test harnesses - (e.g. ember-qunit integration), or remain private? -- Should `compliance` also cover available-stage ids the app opted into via - `enable` (currently: no — use `assert` for those)? -- Interaction with per-import factory deprecations (e.g. the - `deprecate-import-*-from-ember` family): a single flag for the family, or - none? Deferred. +- **Should `compliance` also cover available-stage ids the app opted into + via `enable`?** The proposed default is no: `compliance` is a statement + about `since.enabled` — a fact about the package — so its meaning never + shifts based on another config key. Early adopters lock in available-stage + migrations explicitly via `assert`. A rejected middle ground is a per-id + stage value (`enable: { 'some-id': 'assert' }`), which is more expressive + but grows the API surface before there is demand. +- **Shaken value-exports become `undefined` rather than a build error.** A + true removal at a major deletes the export and fails the app's build; + shaking resolves the import to `undefined` (e.g. the `Comparable` mixin). + The proposed default is to accept this: any code path that goes through + the deprecation still throws the removal error via the runtime backstop, + and `undefined` is a faithful rendering of "this API is gone." The + candidate improvement is build-time: the shaking plugin knows exactly + which exports it emptied and could warn (or error) when an app module + imports one. Worth doing if silent `undefined` bites in practice. - Glimmer VM deprecations use their own override table upstream; wiring them into this system is future work. -- A shaken export read as a value (e.g. the `Comparable` mixin) becomes - `undefined` rather than a build error. True removal at a major would fail - the build instead. Is a lint rule or resolver-level error worth providing? From 8d954da30cda1855c958b08f6cfd9c01f69e0cfc Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 16:59:04 -0700 Subject: [PATCH 09/24] Address review findings in the shaking tooling The deprecation-shaking plugin matches module ids with POSIX separators (vite normalizes ids on every platform; the node:path sep never matched on Windows). The manifest metadata (id/since/until) is now pinned to the DEPRECATIONS registry by a conformance test, and the scan script's marker guidance describes what actually survives prod builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/assert-deprecations-shaken.mjs | 10 ++++++---- lib/deprecation-shaking/index.js | 10 +++++++--- .../-internals/deprecations/tests/index-test.js | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/bin/assert-deprecations-shaken.mjs b/bin/assert-deprecations-shaken.mjs index 3258b6371d6..b22aeb8bb0e 100644 --- a/bin/assert-deprecations-shaken.mjs +++ b/bin/assert-deprecations-shaken.mjs @@ -23,10 +23,12 @@ const { FLAGS, parseFlagsFromEnv, DEFAULT_FLAGS } = require('../broccoli/depreca const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const report = process.argv.includes('--report'); -// Content markers are runtime strings inside a guarded branch (never assert -// or deprecate-call text, which the prod build strips, and never words that -// appear in doc comments — comments are stripped before matching but only -// block comments reliably). +// Content markers are runtime strings inside a guarded branch. deprecateUntil +// message arguments qualify (deprecateUntil is ordinary code, not a stripped +// debug macro) — but only when the call sits inside the guard; entrypoint-style +// stubs keep their message after shaking. Avoid `assert`/`deprecate` call text +// (stripped in prod) and words that appear in doc comments (comments are +// stripped before matching, but only block comments reliably). const CONTENT_MARKERS = { DEPRECATE_COMPARABLE_MIXIN: ['The `Comparable` mixin is deprecated'], // DEPRECATE_IMPORT_INJECT has no content marker: its deprecateUntil message diff --git a/lib/deprecation-shaking/index.js b/lib/deprecation-shaking/index.js index 4b9f5e44898..62315b663af 100644 --- a/lib/deprecation-shaking/index.js +++ b/lib/deprecation-shaking/index.js @@ -1,10 +1,13 @@ import { readFileSync } from 'node:fs'; -import { dirname, resolve, sep } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const emberSourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); -const FLAGS_MODULE_SUFFIX = ['packages', '@ember', 'deprecated-features', 'index.js'].join(sep); +// Vite/rollup module ids use POSIX separators on every platform, so match +// with `/` regardless of the host OS (and normalize just in case a resolver +// hands us a Windows-style path). +const FLAGS_MODULE_SUFFIX = 'packages/@ember/deprecated-features/index.js'; // Numeric segment-wise comparison of dotted version strings (pre-release // tags ignored), so multi-digit minors order correctly (3.28 > 3.4). @@ -81,7 +84,8 @@ export function deprecationShaking({ compliantThrough, strip = [], keep = [] } = load(id) { // Match the resolved flags module by path so this works whether the // dist chunks import it via package self-reference or relative path. - if (id.split('?')[0].endsWith(FLAGS_MODULE_SUFFIX) && id.includes(sep + 'dist' + sep)) { + let path = id.split('?')[0].replace(/\\/g, '/'); + if (path.endsWith(FLAGS_MODULE_SUFFIX) && path.includes('/dist/')) { return code; } }, diff --git a/packages/@ember/-internals/deprecations/tests/index-test.js b/packages/@ember/-internals/deprecations/tests/index-test.js index 0deb462bb3b..a5f2b155f9e 100644 --- a/packages/@ember/-internals/deprecations/tests/index-test.js +++ b/packages/@ember/-internals/deprecations/tests/index-test.js @@ -3,6 +3,7 @@ import { DEPRECATIONS, deprecation, deprecateUntil, isRemoved, emberVersionGte } import { ENV } from '@ember/-internals/environment'; import { setDeprecationStagesConfig } from '@ember/debug'; import * as DEPRECATED_FEATURES from '@ember/deprecated-features'; +import deprecatedFeaturesManifest from '../../../../../broccoli/deprecated-features.cjs'; let originalEnvValue; @@ -38,6 +39,22 @@ moduleFor( } } + ['@test the build manifest metadata matches the registry'](assert) { + // dist/deprecation-flags.json (which the deprecation-shaking plugin's + // `compliantThrough` relies on) is generated from the broccoli + // manifest; this pins its id/since/until to the registry so they + // cannot drift. + let { FLAGS } = deprecatedFeaturesManifest; + + for (let [name, meta] of Object.entries(FLAGS)) { + let entry = DEPRECATIONS[name]; + assert.ok(entry, `${name} exists in the registry`); + assert.strictEqual(meta.id, entry.options.id, `${name} id matches`); + assert.strictEqual(meta.until, entry.options.until, `${name} until matches`); + assert.deepEqual({ ...meta.since }, { ...entry.options.since }, `${name} since matches`); + } + } + ['@test a deprecation whose flag is false reports itself as removed'](assert) { let options = { id: 'test-flagged-off', From 0e9959f48978ccd378961ee3755d608b812f1dbe Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 20:04:02 -0700 Subject: [PATCH 10/24] Create dist/ before writing deprecation-flags.json generateBundle runs before rollup writes output, so on a fresh checkout the directory does not exist yet and every from-scratch build failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- rollup.config.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 6ca30b5a8b9..1b7f8ce39ad 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,5 +1,5 @@ import { dirname, parse, resolve, join } from 'node:path'; -import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { globSync } from 'glob'; @@ -582,6 +582,9 @@ function emitDeprecationFlagsMeta() { since, until, })); + // generateBundle runs before rollup writes its output, so dist/ may + // not exist yet on a fresh checkout + mkdirSync(resolve(projectRoot, 'dist'), { recursive: true }); writeFileSync( resolve(projectRoot, 'dist/deprecation-flags.json'), JSON.stringify(meta, null, 2) + '\n' From a8a30f13a75b088b421e0c31f25f27b49617bc7a Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 20:31:18 -0700 Subject: [PATCH 11/24] Drop the version-coupled isRemoved assertion from the shaking scenario Under _OVERRIDE_DEPRECATION_VERSION simulation an unshaken deprecation legitimately reports removed; the assertion tested version logic, not shaking. Co-Authored-By: Claude Opus 4.8 (1M context) --- smoke-tests/scenarios/deprecation-shaking-test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/smoke-tests/scenarios/deprecation-shaking-test.ts b/smoke-tests/scenarios/deprecation-shaking-test.ts index 1a623e4b09c..5353c677261 100644 --- a/smoke-tests/scenarios/deprecation-shaking-test.ts +++ b/smoke-tests/scenarios/deprecation-shaking-test.ts @@ -76,10 +76,9 @@ Scenarios.fromProject(() => test('flag state is consistent with runtime behavior', function (assert) { if (DEPRECATE_COMPARABLE_MIXIN) { assert.true(Boolean(Comparable), 'Comparable mixin exists while the flag is on'); - assert.false( - DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN.isRemoved, - 'not removed while the flag is on' - ); + // no isRemoved assertion here: version simulation + // (_OVERRIDE_DEPRECATION_VERSION) can legitimately make an + // unshaken deprecation report removed } else { assert.strictEqual(Comparable, undefined, 'Comparable mixin is shaken away'); assert.true( From 37b9a396acc16d10090a42bf5f701f826fd5656e Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:24:20 -0700 Subject: [PATCH 12/24] Let except exclude ids from enable in DEPRECATION_STAGES except now means "treat this id as unconfigured": exempt from compliance/assert throwing and excluded from enable, including enable: true. EXCEPT_DEPRECATIONS threads through testem/index.html so CI variants can blanket-enable available deprecations minus a known-noisy list. Co-Authored-By: Claude Opus 4.8 (1M context) --- index.html | 9 ++++++++- .../deprecation-early-enablement-and-shaking.md | 9 +++++++-- packages/@ember/debug/lib/deprecation-stages.ts | 7 +++++-- .../@ember/debug/tests/deprecation-stages-test.js | 14 ++++++++++++++ testem.cjs | 5 +++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 95971d4a273..b1187b65d2f 100644 --- a/index.html +++ b/index.html @@ -32,7 +32,11 @@ EmberENV['_OVERRIDE_DEPRECATION_VERSION'] = QUnit.urlParams.OVERRIDE_DEPRECATION_VERSION; } - if (QUnit.urlParams.ENABLED_DEPRECATIONS || QUnit.urlParams.DEPRECATION_COMPLIANCE) { + if ( + QUnit.urlParams.ENABLED_DEPRECATIONS || + QUnit.urlParams.DEPRECATION_COMPLIANCE || + QUnit.urlParams.EXCEPT_DEPRECATIONS + ) { EmberENV['DEPRECATION_STAGES'] = {}; if (QUnit.urlParams.ENABLED_DEPRECATIONS) { EmberENV['DEPRECATION_STAGES'].enable = @@ -43,6 +47,9 @@ if (QUnit.urlParams.DEPRECATION_COMPLIANCE) { EmberENV['DEPRECATION_STAGES'].compliance = QUnit.urlParams.DEPRECATION_COMPLIANCE; } + if (QUnit.urlParams.EXCEPT_DEPRECATIONS) { + EmberENV['DEPRECATION_STAGES'].except = QUnit.urlParams.EXCEPT_DEPRECATIONS.split(','); + } } QUnit.config.urlConfig.push({ diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index 2ce841d6afa..545aa07e7da 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -97,7 +97,9 @@ interface DeprecationStagesConfig { assert?: string[]; /** - * Escape hatch: ids exempted from `compliance`/`assert` throwing. + * Escape hatch: ids this configuration treats as unconfigured — exempted + * from `compliance`/`assert` throwing and excluded from `enable` + * (including `enable: true`). */ except?: string[]; } @@ -128,7 +130,10 @@ Semantics: their `until` version. It therefore applies to *all* deprecations flowing through `@ember/debug` — including addon deprecations with their own `for` — not only Ember's own. -- Precedence: `except` > `assert` > `compliance`. +- Precedence: `except` > `assert` > `compliance`, and `except` also excludes + an id from `enable`. `except` means "pretend this id is not configured" — + the lever that lets `enable: true` coexist with a handful of + known-too-noisy ids. - A compliance declaration for a package version newer than the installed version is invalid (asserts), mirroring RFC 0649's rule against optimistic declarations. diff --git a/packages/@ember/debug/lib/deprecation-stages.ts b/packages/@ember/debug/lib/deprecation-stages.ts index 02797d93333..9905cf2e31e 100644 --- a/packages/@ember/debug/lib/deprecation-stages.ts +++ b/packages/@ember/debug/lib/deprecation-stages.ts @@ -39,7 +39,9 @@ export interface DeprecationStagesConfig { assert?: string[]; /** - Ids exempted from `compliance`/`assert` throwing. + Ids this configuration should treat as unconfigured: exempted from + `compliance`/`assert` throwing and from `enable` (including + `enable: true`). */ except?: string[]; } @@ -156,7 +158,8 @@ if (DEBUG) { let current = normalize(ENV.DEPRECATION_STAGES as DeprecationStagesConfig | null); - isDeprecationEnabledByConfig = (id) => current.enableAll || current.enabledIds.has(id); + isDeprecationEnabledByConfig = (id) => + (current.enableAll || current.enabledIds.has(id)) && !current.exceptIds.has(id); shouldThrowForDeprecation = (options) => { if (current.exceptIds.has(options.id)) { diff --git a/packages/@ember/debug/tests/deprecation-stages-test.js b/packages/@ember/debug/tests/deprecation-stages-test.js index 5794d6edf4b..4cf7b80f76b 100644 --- a/packages/@ember/debug/tests/deprecation-stages-test.js +++ b/packages/@ember/debug/tests/deprecation-stages-test.js @@ -145,6 +145,20 @@ moduleForDevelopment( assert.ok(true, 'unlisted ids do not throw'); } + ['@test except excludes an id from enable'](assert) { + setDeprecationStagesConfig({ enable: true, except: ['excluded-id'] }); + + assert.true(isDeprecationEnabledByConfig('any-other-id'), 'enable: true still applies'); + assert.false(isDeprecationEnabledByConfig('excluded-id'), 'excepted id is not enabled'); + + setDeprecationStagesConfig({ enable: ['listed-id'], except: ['listed-id'] }); + + assert.false( + isDeprecationEnabledByConfig('listed-id'), + 'except wins over an explicit enable listing' + ); + } + ['@test except exempts an id from compliance and assert'](assert) { setDeprecationStagesConfig({ compliance: '6.1.0', diff --git a/testem.cjs b/testem.cjs index 33ecbc3eaf1..3c95a011ada 100644 --- a/testem.cjs +++ b/testem.cjs @@ -20,6 +20,11 @@ const variants = [ // enabled at or before this ember-source version throw instead of warning. 'DEPRECATION_COMPLIANCE', + // Comma-separated deprecation ids passed to + // EmberENV.DEPRECATION_STAGES.except: treated as unconfigured — excluded + // from enable (including ENABLED_DEPRECATIONS=true) and from throwing. + 'EXCEPT_DEPRECATIONS', + // This enables all canary feature flags for unreleased feature within Ember // itself. 'ENABLE_OPTIONAL_FEATURES', From f342cd0f7854c35bde056c36824738c972464059 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:34:54 -0700 Subject: [PATCH 13/24] Route framework class definitions through internal non-deprecating aliases internalExtend/internalReopen/internalReopenClass (@ember/object/core) and createMixin (@ember/object/mixin) are the entry points for ember-source's own framework definitions; the public statics delegate to them. This includes the runtime sites (autoboot Router extend, Router.map's reopenClass, engine initializer buckets) and the PrototypeMixin/willReopen machinery, so upcoming definition-time deprecations on the public statics never fire from framework internals. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../-internals/glimmer/lib/component.ts | 9 ++- .../-internals/runtime/lib/mixins/-proxy.ts | 4 +- .../runtime/lib/mixins/action_handler.ts | 4 +- .../runtime/lib/mixins/comparable.ts | 4 +- .../runtime/lib/mixins/container_proxy.ts | 4 +- .../runtime/lib/mixins/registry_proxy.ts | 4 +- .../lib/mixins/target_action_support.ts | 4 +- .../views/lib/mixins/action_support.ts | 4 +- .../-internals/views/lib/views/core_view.ts | 3 +- packages/@ember/application/index.ts | 3 +- packages/@ember/array/index.ts | 8 +-- packages/@ember/array/proxy.ts | 3 +- packages/@ember/controller/index.ts | 7 ++- packages/@ember/engine/index.ts | 5 +- packages/@ember/engine/instance.ts | 3 +- packages/@ember/enumerable/index.ts | 4 +- packages/@ember/enumerable/mutable.ts | 4 +- packages/@ember/object/core.ts | 63 +++++++++++++++---- packages/@ember/object/evented.ts | 4 +- packages/@ember/object/index.ts | 4 +- packages/@ember/object/mixin.ts | 11 ++++ packages/@ember/object/observable.ts | 4 +- packages/@ember/object/promise-proxy-mixin.ts | 4 +- .../@ember/routing/lib/routing-service.ts | 3 +- packages/@ember/routing/none-location.ts | 3 +- packages/@ember/routing/route.ts | 8 ++- packages/@ember/routing/router-service.ts | 3 +- packages/@ember/routing/router.ts | 7 ++- .../ember-testing/lib/adapters/adapter.ts | 3 +- 29 files changed, 131 insertions(+), 63 deletions(-) diff --git a/packages/@ember/-internals/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index b4f64601824..c79f30a3405 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -13,6 +13,7 @@ import { getViewElement, } from '@ember/-internals/views/lib/system/utils'; import CoreView from '@ember/-internals/views/lib/views/core_view'; +import { internalExtend, internalReopenClass } from '@ember/object/core'; import EventDispatcher from '@ember/-internals/views/lib/system/event_dispatcher'; import { guidFor } from '@ember/-internals/utils/lib/guid'; import { assert } from '@ember/debug'; @@ -802,7 +803,8 @@ interface Component extends CoreView, TargetActionSupport, ActionSupport, ComponentMethods {} class Component - extends CoreView.extend( + extends internalExtend( + CoreView, TargetActionSupport, ActionSupport, { @@ -1688,8 +1690,9 @@ class Component } } -// We continue to use reopenClass here so that positionalParams can be overridden with reopenClass in subclasses. -Component.reopenClass({ +// We continue to use the reopenClass mechanism here so that positionalParams +// can be overridden with reopenClass in subclasses. +internalReopenClass(Component, { positionalParams: [], }); diff --git a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts index e70909d244d..d2093836a61 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts @@ -3,7 +3,7 @@ */ import { meta } from '@ember/-internals/meta/lib/meta'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { get } from '@ember/-internals/metal/lib/property_get'; import { set } from '@ember/-internals/metal/lib/property_set'; import { defineProperty } from '@ember/-internals/metal/lib/properties'; @@ -90,7 +90,7 @@ interface ProxyMixin { setUnknownProperty(key: string, value: V): V; } -const ProxyMixin = /*@__PURE__*/ Mixin.create({ +const ProxyMixin = /*@__PURE__*/ createMixin({ /** The object whose properties will be forwarded. diff --git a/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts b/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts index bab591600ee..120edeb1354 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts @@ -2,7 +2,7 @@ @module ember */ -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { get } from '@ember/-internals/metal/lib/property_get'; import { assert } from '@ember/debug'; @@ -21,7 +21,7 @@ interface ActionHandler { actions?: Record unknown>; send(actionName: string, ...args: unknown[]): void; } -const ActionHandler = Mixin.create({ +const ActionHandler = createMixin({ mergedProperties: ['actions'], /** diff --git a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts index 507a4e810fe..6cf802fbe5c 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts @@ -1,4 +1,4 @@ -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; @@ -21,7 +21,7 @@ interface Comparable { compare: ((a: unknown, b: unknown) => -1 | 0 | 1) | null; } const Comparable = DEPRECATE_COMPARABLE_MIXIN - ? Mixin.create({ + ? createMixin({ /** __Required.__ You must implement this method to apply this mixin. diff --git a/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts index 9b9a870a7c9..81bc72df69d 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts @@ -3,7 +3,7 @@ import { schedule, join } from '@ember/runloop'; @module ember */ import type Container from '@ember/-internals/container/lib/container'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import type { ContainerProxy } from '@ember/-internals/owner'; // This is defined as a separate interface so that it can be used in the definition of @@ -21,7 +21,7 @@ interface ContainerProxyMixin extends ContainerProxy { /** @internal */ __container__: Container; } -const ContainerProxyMixin = Mixin.create({ +const ContainerProxyMixin = createMixin({ /** The container stores state. diff --git a/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts index 600b2bf62f2..225af3329cb 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts @@ -7,7 +7,7 @@ import type { RegistryProxy } from '@ember/-internals/owner'; import type { AnyFn } from '@ember/-internals/utility-types'; import { assert } from '@ember/debug'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; /** RegistryProxyMixin is used to provide public access to specific @@ -21,7 +21,7 @@ interface RegistryProxyMixin extends RegistryProxy { /** @internal */ __registry__: Registry; } -const RegistryProxyMixin = Mixin.create({ +const RegistryProxyMixin = createMixin({ __registry__: null, resolveRegistration(fullName: string) { diff --git a/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts b/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts index 534bf7c68b8..a7752090fc1 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts @@ -5,7 +5,7 @@ import { context } from '@ember/-internals/environment/lib/context'; import { get } from '@ember/-internals/metal/lib/property_get'; import computed from '@ember/-internals/metal/lib/computed'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; @@ -31,7 +31,7 @@ interface TargetActionSupport { /** @internal */ _target?: unknown; } -const TargetActionSupport = Mixin.create({ +const TargetActionSupport = createMixin({ target: null, action: null, actionContext: null, diff --git a/packages/@ember/-internals/views/lib/mixins/action_support.ts b/packages/@ember/-internals/views/lib/mixins/action_support.ts index a4c95d28ac3..87bb89471b3 100644 --- a/packages/@ember/-internals/views/lib/mixins/action_support.ts +++ b/packages/@ember/-internals/views/lib/mixins/action_support.ts @@ -2,7 +2,7 @@ @module ember */ import { get } from '@ember/-internals/metal/lib/property_get'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import inspect from '@ember/debug/lib/inspect'; import { assert } from '@ember/debug'; @@ -14,7 +14,7 @@ import { assert } from '@ember/debug'; interface ActionSupport { send(actionName: string, ...args: unknown[]): void; } -const ActionSupport = Mixin.create({ +const ActionSupport = createMixin({ send(actionName: string, ...args: unknown[]) { assert( `Attempted to call .send() with the action '${actionName}' on the destroyed object '${this}'.`, diff --git a/packages/@ember/-internals/views/lib/views/core_view.ts b/packages/@ember/-internals/views/lib/views/core_view.ts index ceb6c0cfa8f..666bfcc80ef 100644 --- a/packages/@ember/-internals/views/lib/views/core_view.ts +++ b/packages/@ember/-internals/views/lib/views/core_view.ts @@ -2,6 +2,7 @@ import type { Renderer, View } from '@ember/-internals/glimmer/lib/renderer'; import inject from '@ember/-internals/metal/lib/injected_property'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import Evented from '@ember/object/evented'; +import { internalExtend } from '@ember/object/core'; import { FrameworkObject } from '@ember/object/-internals'; import type { ViewState } from './states'; import states from './states'; @@ -24,7 +25,7 @@ import states from './states'; */ interface CoreView extends Evented, ActionHandler, View {} -class CoreView extends FrameworkObject.extend(Evented, ActionHandler) { +class CoreView extends internalExtend(FrameworkObject, Evented, ActionHandler) { isView = true; declare _states: typeof states; diff --git a/packages/@ember/application/index.ts b/packages/@ember/application/index.ts index e4ac76f8de3..bab27ecc214 100644 --- a/packages/@ember/application/index.ts +++ b/packages/@ember/application/index.ts @@ -15,6 +15,7 @@ import RSVP from '@ember/-internals/runtime/lib/ext/rsvp'; import EventDispatcher from '@ember/-internals/views/lib/system/event_dispatcher'; import Route from '@ember/routing/route'; import Router from '@ember/routing/router'; +import { internalExtend } from '@ember/object/core'; import HashLocation from '@ember/routing/hash-location'; import HistoryLocation from '@ember/routing/history-location'; import NoneLocation from '@ember/routing/none-location'; @@ -386,7 +387,7 @@ class Application extends Engine { // Create subclass of Router for this Application instance. // This is to ensure that someone reopening `App.Router` does not // tamper with the default `Router`. - this.Router = (this.Router || Router).extend() as typeof Router; + this.Router = internalExtend(this.Router || Router) as typeof Router; this._buildDeprecatedInstance(); this.waitForDOMReady(); diff --git a/packages/@ember/array/index.ts b/packages/@ember/array/index.ts index 37d0eb3c8e0..2901866ed35 100644 --- a/packages/@ember/array/index.ts +++ b/packages/@ember/array/index.ts @@ -10,7 +10,7 @@ import { } from '@ember/-internals/metal/lib/property_events'; import { get } from '@ember/-internals/metal/lib/property_get'; import { set } from '@ember/-internals/metal/lib/property_set'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; import Enumerable from '@ember/enumerable'; import MutableEnumerable from '@ember/enumerable/mutable'; @@ -1139,7 +1139,7 @@ interface EmberArray extends Enumerable { */ without(value: T): NativeArray; } -const EmberArray = Mixin.create(Enumerable, { +const EmberArray = createMixin(Enumerable, { init() { this._super(...arguments); setEmberArray(this); @@ -1687,7 +1687,7 @@ interface MutableArray extends EmberArray, MutableEnumerable { */ addObjects(objects: T[]): this; } -const MutableArray = Mixin.create(EmberArray, MutableEnumerable, { +const MutableArray = createMixin(EmberArray, MutableEnumerable, { clear() { let len = this.length; if (len === 0) { @@ -2023,7 +2023,7 @@ interface MutableArrayWithoutNative extends Omit< */ interface NativeArray extends Array, Observable, MutableArrayWithoutNative {} -let NativeArray = Mixin.create(MutableArray, Observable, { +let NativeArray = createMixin(MutableArray, Observable, { objectAt(idx: number) { return this[idx]; }, diff --git a/packages/@ember/array/proxy.ts b/packages/@ember/array/proxy.ts index d4a9907c2ff..331cb4248cb 100644 --- a/packages/@ember/array/proxy.ts +++ b/packages/@ember/array/proxy.ts @@ -15,6 +15,7 @@ import { get } from '@ember/-internals/metal/lib/property_get'; import type { PropertyDidChange } from '@ember/-internals/metal/lib/property_events'; import { isObject } from '@ember/-internals/utils/lib/spec'; import EmberObject from '@ember/object'; +import { internalReopen } from '@ember/object/core'; import EmberArray, { type NativeArray } from '@ember/array'; import MutableArray from '@ember/array/mutable'; import { assert } from '@ember/debug'; @@ -405,7 +406,7 @@ class ArrayProxy extends EmberObject implements PropertyDidChange { } } -ArrayProxy.reopen(MutableArray, { +internalReopen(ArrayProxy, MutableArray, { arrangedContent: alias('content'), }); diff --git a/packages/@ember/controller/index.ts b/packages/@ember/controller/index.ts index 0d86064d773..695ddd5ec93 100644 --- a/packages/@ember/controller/index.ts +++ b/packages/@ember/controller/index.ts @@ -2,12 +2,13 @@ import { getOwner } from '@ember/-internals/owner'; // This is imported from -in import computed from '@ember/-internals/metal/lib/computed'; import { get } from '@ember/-internals/metal/lib/property_get'; import { FrameworkObject } from '@ember/object/-internals'; +import { internalExtend } from '@ember/object/core'; import metalInject from '@ember/-internals/metal/lib/injected_property'; import type { DecoratorPropertyDescriptor, ElementDescriptor, } from '@ember/-internals/metal/lib/decorator'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import type { RouteArgs } from '@ember/routing/-internals'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import type { Transition } from 'router_js'; @@ -233,7 +234,7 @@ interface ControllerMixin extends ActionHandler { */ replaceRoute(...args: RouteArgs): Transition; } -const ControllerMixin = Mixin.create(ActionHandler, { +const ControllerMixin = createMixin(ActionHandler, { /* ducktype as a controller */ isController: true, @@ -315,7 +316,7 @@ const ControllerMixin = Mixin.create(ActionHandler, { @public */ interface Controller<_T = unknown> extends FrameworkObject, ControllerMixin<_T> {} -class Controller<_T = unknown> extends FrameworkObject.extend(ControllerMixin) {} +class Controller<_T = unknown> extends internalExtend(FrameworkObject, ControllerMixin) {} /** Creates a property that lazily looks up another controller in the container. diff --git a/packages/@ember/engine/index.ts b/packages/@ember/engine/index.ts index 478de9e9b13..2bd0eea5756 100644 --- a/packages/@ember/engine/index.ts +++ b/packages/@ember/engine/index.ts @@ -3,6 +3,7 @@ export { getEngineParent, setEngineParent } from './parent'; import { canInvoke } from '@ember/-internals/utils/lib/invoke'; import Controller from '@ember/controller'; import Namespace from '@ember/application/namespace'; +import { internalExtend, internalReopenClass } from '@ember/object/core'; import Registry from '@ember/-internals/container/lib/registry'; import type { ResolverClass } from '@ember/-internals/container/lib/registry'; import DAG from 'dag-map'; @@ -56,7 +57,7 @@ export interface Initializer { */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Engine extends RegistryProxyMixin {} -class Engine extends Namespace.extend(RegistryProxyMixin) { +class Engine extends internalExtend(Namespace, RegistryProxyMixin) { static initializers: Record> = Object.create(null); static instanceInitializers: Record> = Object.create(null); @@ -494,7 +495,7 @@ export function buildInitializerMethod< let attrs = { [bucketName]: Object.create(this[bucketName]), }; - this.reopenClass(attrs); + internalReopenClass(this, attrs); } assert( diff --git a/packages/@ember/engine/instance.ts b/packages/@ember/engine/instance.ts index 050d27eb3b8..9fedb50dd70 100644 --- a/packages/@ember/engine/instance.ts +++ b/packages/@ember/engine/instance.ts @@ -3,6 +3,7 @@ */ import EmberObject from '@ember/object'; +import { internalExtend } from '@ember/object/core'; import RSVP from '@ember/-internals/runtime/lib/ext/rsvp'; import { assert } from '@ember/debug'; import { default as Registry, privatize as P } from '@ember/-internals/container/lib/registry'; @@ -53,7 +54,7 @@ export interface EngineInstanceOptions { // type checking, we have broken part of our public API contract. Medium-term, // the goal here is to `EngineInstance` simple be `Owner`. interface EngineInstance extends RegistryProxyMixin, ContainerProxyMixin, InternalOwner, Owner {} -class EngineInstance extends EmberObject.extend(RegistryProxyMixin, ContainerProxyMixin) { +class EngineInstance extends internalExtend(EmberObject, RegistryProxyMixin, ContainerProxyMixin) { /** @private @method setupRegistry diff --git a/packages/@ember/enumerable/index.ts b/packages/@ember/enumerable/index.ts index 2929953363b..84b5da22bf6 100644 --- a/packages/@ember/enumerable/index.ts +++ b/packages/@ember/enumerable/index.ts @@ -1,4 +1,4 @@ -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; /** @module @ember/enumerable @@ -15,6 +15,6 @@ import Mixin from '@ember/object/mixin'; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Enumerable {} -const Enumerable = Mixin.create(); +const Enumerable = createMixin(); export default Enumerable; diff --git a/packages/@ember/enumerable/mutable.ts b/packages/@ember/enumerable/mutable.ts index 5f1b01182b9..fe97c6e3d05 100644 --- a/packages/@ember/enumerable/mutable.ts +++ b/packages/@ember/enumerable/mutable.ts @@ -1,5 +1,5 @@ import Enumerable from '@ember/enumerable'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; /** @module ember @@ -17,6 +17,6 @@ import Mixin from '@ember/object/mixin'; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface MutableEnumerable extends Enumerable {} -const MutableEnumerable = Mixin.create(Enumerable); +const MutableEnumerable = createMixin(Enumerable); export default MutableEnumerable; diff --git a/packages/@ember/object/core.ts b/packages/@ember/object/core.ts index 858015174fb..febf4185d16 100644 --- a/packages/@ember/object/core.ts +++ b/packages/@ember/object/core.ts @@ -14,7 +14,7 @@ import { activateObserver } from '@ember/-internals/metal/lib/observer'; import { defineProperty } from '@ember/-internals/metal/lib/properties'; import { descriptorForProperty, isClassicDecorator } from '@ember/-internals/metal/lib/decorator'; import { DEBUG_INJECTION_FUNCTIONS } from '@ember/-internals/metal/lib/injected_property'; -import Mixin, { applyMixin } from '@ember/object/mixin'; +import Mixin, { applyMixin, createMixin } from '@ember/object/mixin'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import makeArray from '@ember/array/make'; import { assert } from '@ember/debug'; @@ -711,10 +711,8 @@ class CoreObject { this: Statics & EmberClassConstructor, ...mixins: M ): Readonly & EmberClassConstructor & MergeArray; - static extend(...mixins: any[]) { - let Class = class extends this {}; - reopen.apply(Class.PrototypeMixin, mixins); - return Class; + static extend(this: typeof CoreObject, ...mixins: any[]) { + return internalExtend(this, ...mixins); } /** @@ -837,9 +835,7 @@ class CoreObject { @public */ static reopen(this: C, ...args: any[]): C { - this.willReopen(); - reopen.apply(this.PrototypeMixin, args); - return this; + return internalReopen(this, ...args); } static willReopen() { @@ -851,7 +847,7 @@ class CoreObject { // make sure that it gets properly applied. Reusing the same mixin after // the first `proto` call will cause it to get skipped. if (prototypeMixinMap.has(this)) { - prototypeMixinMap.set(this, Mixin.create(this.PrototypeMixin)); + prototypeMixinMap.set(this, createMixin(this.PrototypeMixin)); } } } @@ -921,8 +917,7 @@ class CoreObject { this: C, ...mixins: Array> ): C { - applyMixin(this, mixins); - return this; + return internalReopenClass(this, ...mixins); } static detect(obj: unknown) { @@ -1010,7 +1005,7 @@ class CoreObject { static get PrototypeMixin() { let prototypeMixin = prototypeMixinMap.get(this); if (prototypeMixin === undefined) { - prototypeMixin = Mixin.create(); + prototypeMixin = createMixin(); prototypeMixin.ownerConstructor = this; prototypeMixinMap.set(this, prototypeMixin); } @@ -1129,4 +1124,48 @@ if (DEBUG) { }; } +/** + Non-deprecating equivalent of `CoreObject.extend` for ember-source's own + framework class definitions. External code must use the public static. + + @internal +*/ +export function internalExtend>( + Base: Statics & EmberClassConstructor, + ...mixins: M +): Readonly & EmberClassConstructor & MergeArray; +export function internalExtend(Base: any, ...mixins: any[]) { + let Class = class extends Base {}; + reopen.apply((Class as unknown as typeof CoreObject).PrototypeMixin, mixins); + return Class; +} + +/** + Non-deprecating equivalent of the static `reopen` for ember-source's own + framework definitions. Unlike bare `Class.PrototypeMixin.reopen(...)`, this + keeps the `willReopen` cache invalidation, so it is safe after first + instantiation. + + @internal +*/ +export function internalReopen(Class: C, ...args: any[]): C { + Class.willReopen(); + reopen.apply(Class.PrototypeMixin, args); + return Class; +} + +/** + Non-deprecating equivalent of the static `reopenClass` for ember-source's + own framework definitions. + + @internal +*/ +export function internalReopenClass( + Class: C, + ...mixins: Array> +): C { + applyMixin(Class, mixins); + return Class; +} + export default CoreObject; diff --git a/packages/@ember/object/evented.ts b/packages/@ember/object/evented.ts index 097d274d4bf..cf6fc40d767 100644 --- a/packages/@ember/object/evented.ts +++ b/packages/@ember/object/evented.ts @@ -4,7 +4,7 @@ import { hasListeners, sendEvent, } from '@ember/-internals/metal/lib/events'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; export { on } from '@ember/-internals/metal/lib/events'; @@ -149,7 +149,7 @@ interface Evented { */ has(name: string): boolean; } -const Evented = Mixin.create({ +const Evented = createMixin({ on(name: string, target: object, method?: string | Function) { addListener(this, name, target, method); return this; diff --git a/packages/@ember/object/index.ts b/packages/@ember/object/index.ts index 1bf0587949a..3b74a20cf01 100644 --- a/packages/@ember/object/index.ts +++ b/packages/@ember/object/index.ts @@ -9,7 +9,7 @@ import expandProperties from '@ember/-internals/metal/lib/expand_properties'; import { getFactoryFor } from '@ember/-internals/container/lib/container'; import { setObservers } from '@ember/-internals/utils/lib/super'; import type { AnyFn } from '@ember/-internals/utility-types'; -import CoreObject from '@ember/object/core'; +import CoreObject, { internalExtend } from '@ember/object/core'; import Observable from '@ember/object/observable'; export { notifyPropertyChange } from '@ember/-internals/metal/lib/property_events'; @@ -36,7 +36,7 @@ export { default as computed } from '@ember/-internals/metal/lib/computed'; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface EmberObject extends Observable {} -class EmberObject extends CoreObject.extend(Observable) { +class EmberObject extends internalExtend(CoreObject, Observable) { get _debugContainerKey() { let factory = getFactoryFor(this); return factory !== undefined && factory.fullName; diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index c76b57f7dbe..258e190d919 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -687,6 +687,17 @@ export default class Mixin { } } +/** + Non-deprecating equivalent of `Mixin.create` for ember-source's own + framework mixins. External code must use `Mixin.create`. + + @internal +*/ +export function createMixin(...args: any[]): Mixin { + setUnprocessedMixins(); + return new Mixin(args, undefined); +} + if (DEBUG) { Object.defineProperty(Mixin, '_disableDebugSeal', { configurable: true, diff --git a/packages/@ember/object/observable.ts b/packages/@ember/object/observable.ts index b35c3dcd11f..b1438c05175 100644 --- a/packages/@ember/object/observable.ts +++ b/packages/@ember/object/observable.ts @@ -15,7 +15,7 @@ import { set } from '@ember/-internals/metal/lib/property_set'; import getProperties from '@ember/-internals/metal/lib/get_properties'; import setProperties from '@ember/-internals/metal/lib/set_properties'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; export type ObserverMethod = @@ -420,7 +420,7 @@ interface Observable { */ cacheFor(key: K): unknown; } -const Observable = Mixin.create({ +const Observable = createMixin({ get(keyName: string) { return get(this, keyName); }, diff --git a/packages/@ember/object/promise-proxy-mixin.ts b/packages/@ember/object/promise-proxy-mixin.ts index 9571771a803..e6acde45487 100644 --- a/packages/@ember/object/promise-proxy-mixin.ts +++ b/packages/@ember/object/promise-proxy-mixin.ts @@ -1,7 +1,7 @@ import { get } from '@ember/-internals/metal/lib/property_get'; import setProperties from '@ember/-internals/metal/lib/set_properties'; import computed from '@ember/-internals/metal/lib/computed'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import type { AnyFn, MethodNamesOf } from '@ember/-internals/utility-types'; import type RSVP from 'rsvp'; import type CoreObject from '@ember/object/core'; @@ -211,7 +211,7 @@ interface PromiseProxyMixin { */ finally: this['promise']['finally']; } -const PromiseProxyMixin = Mixin.create({ +const PromiseProxyMixin = createMixin({ reason: null, isPending: computed('isSettled', function () { diff --git a/packages/@ember/routing/lib/routing-service.ts b/packages/@ember/routing/lib/routing-service.ts index 610fcdac4e9..10cd5b848a4 100644 --- a/packages/@ember/routing/lib/routing-service.ts +++ b/packages/@ember/routing/lib/routing-service.ts @@ -5,6 +5,7 @@ import { getOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; import { readOnly } from '@ember/object/computed'; +import { internalReopen } from '@ember/object/core'; import Service from '@ember/service'; import type { ModelFor } from 'router_js'; import type Route from '@ember/routing/route'; @@ -128,7 +129,7 @@ export default class RoutingService extends Service { } } -RoutingService.reopen({ +internalReopen(RoutingService, { targetState: readOnly('router.targetState'), currentState: readOnly('router.currentState'), currentRouteName: readOnly('router.currentRouteName'), diff --git a/packages/@ember/routing/none-location.ts b/packages/@ember/routing/none-location.ts index e396d8ef335..b29582b2759 100644 --- a/packages/@ember/routing/none-location.ts +++ b/packages/@ember/routing/none-location.ts @@ -1,4 +1,5 @@ import EmberObject from '@ember/object'; +import { internalReopen } from '@ember/object/core'; import { assert } from '@ember/debug'; import type { default as EmberLocation, UpdateCallback } from '@ember/routing/location'; import { escapeRegExp } from './lib/location-utils'; @@ -127,7 +128,7 @@ export default class NoneLocation extends EmberObject implements EmberLocation { } } -NoneLocation.reopen({ +internalReopen(NoneLocation, { path: '', rootURL: '/', }); diff --git a/packages/@ember/routing/route.ts b/packages/@ember/routing/route.ts index 4e05176c34e..dc5570ce750 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -11,6 +11,7 @@ import { set } from '@ember/-internals/metal/lib/property_set'; import getProperties from '@ember/-internals/metal/lib/get_properties'; import setProperties from '@ember/-internals/metal/lib/set_properties'; import EmberObject from '@ember/object'; +import { internalExtend, internalReopen } from '@ember/object/core'; import Evented from '@ember/object/evented'; import { A as emberA } from '@ember/array'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; @@ -256,7 +257,10 @@ interface Route extends IRoute, ActionHandler, Evented { error?(error: Error, transition: Transition): boolean | void; } -class Route extends EmberObject.extend(ActionHandler, Evented) implements IRoute { +class Route + extends internalExtend(EmberObject, ActionHandler, Evented) + implements IRoute +{ static isRouteFactory = true; // These properties will end up appearing in the public interface because we @@ -2039,7 +2043,7 @@ export function hasDefaultSerialize(route: Route): boolean { } // Set these here so they can be overridden with extend -Route.reopen({ +internalReopen(Route, { mergedProperties: ['queryParams'], queryParams: {}, templateName: null, diff --git a/packages/@ember/routing/router-service.ts b/packages/@ember/routing/router-service.ts index 9fc5e3b4df1..50ebcb8626b 100644 --- a/packages/@ember/routing/router-service.ts +++ b/packages/@ember/routing/router-service.ts @@ -3,6 +3,7 @@ */ import { getOwner } from '@ember/-internals/owner'; import Evented from '@ember/object/evented'; +import { internalExtend } from '@ember/object/core'; import { assert } from '@ember/debug'; import { readOnly } from '@ember/object/computed'; import Service from '@ember/service'; @@ -62,7 +63,7 @@ interface RouterService extends Evented { callback: (transition: Transition) => void ): this; } -class RouterService extends Service.extend(Evented) { +class RouterService extends internalExtend(Service, Evented) { [ROUTER]?: EmberRouter; get _router(): EmberRouter { diff --git a/packages/@ember/routing/router.ts b/packages/@ember/routing/router.ts index 7f6d760dd9d..86615738471 100644 --- a/packages/@ember/routing/router.ts +++ b/packages/@ember/routing/router.ts @@ -27,6 +27,7 @@ import type { } from '@ember/routing/location'; import type RouterService from '@ember/routing/router-service'; import EmberObject from '@ember/object'; +import { internalExtend, internalReopen, internalReopenClass } from '@ember/object/core'; import { A as emberA } from '@ember/array'; import typeOf from '@ember/utils/lib/type-of'; import Evented from '@ember/object/evented'; @@ -142,7 +143,7 @@ const { slice } = Array.prototype; @uses Evented @public */ -class EmberRouter extends EmberObject.extend(Evented) implements Evented { +class EmberRouter extends internalExtend(EmberObject, Evented) implements Evented { /** Represents the URL of the root of the application, often '/'. This prefix is assumed on all routes defined on this router. @@ -254,7 +255,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { if (!this.dslCallbacks) { this.dslCallbacks = []; // FIXME: Can we remove this? - this.reopenClass({ dslCallbacks: this.dslCallbacks }); + internalReopenClass(this, { dslCallbacks: this.dslCallbacks }); } this.dslCallbacks.push(callback); @@ -1814,7 +1815,7 @@ function forEachQueryParam( } } -EmberRouter.reopen({ +internalReopen(EmberRouter, { didTransition: defaultDidTransition, willTransition: defaultWillTransition, rootURL: '/', diff --git a/packages/ember-testing/lib/adapters/adapter.ts b/packages/ember-testing/lib/adapters/adapter.ts index 26876da9d42..761461fc31b 100644 --- a/packages/ember-testing/lib/adapters/adapter.ts +++ b/packages/ember-testing/lib/adapters/adapter.ts @@ -1,4 +1,5 @@ import EmberObject from '@ember/object'; +import { internalExtend } from '@ember/object/core'; /** @module @ember/test @@ -16,7 +17,7 @@ interface Adapter extends EmberObject { asyncEnd(): void; exception(error: unknown): never; } -const Adapter = EmberObject.extend({ +const Adapter = internalExtend(EmberObject, { /** This callback will be called whenever an async operation is about to start. From a738cc9d378c1520ea50b28b499e76df1fe97ee7 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:42:13 -0700 Subject: [PATCH 14/24] Deprecate extend, reopen, reopenClass, and Mixin.create (available stage) deprecate-ember-object-extend, deprecate-ember-object-reopen, and deprecate-ember-mixins fire from the public statics only; framework internals go through the internal aliases. Available-stage: silent unless opted into via DEPRECATION_STAGES. The blanket CI variant rows move from _ALL_DEPRECATIONS_ENABLED to ENABLED_DEPRECATIONS=true with these ids excepted, since ember's own suite still exercises the classic APIs pervasively. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 14 +- .../@ember/-internals/deprecations/index.ts | 26 ++++ packages/@ember/object/core.ts | 13 ++ packages/@ember/object/mixin.ts | 5 + .../classic-object-model-deprecations-test.js | 139 ++++++++++++++++++ 5 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 packages/@ember/object/tests/classic-object-model-deprecations-test.js diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index 262e2077b08..506c00a9ef2 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -99,13 +99,17 @@ jobs: fail-fast: false matrix: include: + # The classic object model deprecations are excluded from the + # blanket rows: ember's own test suite (and internal-test-helpers) + # still exercises those APIs pervasively. They get targeted + # coverage in packages/@ember/object/tests instead. - name: "All deprecations enabled" - ALL_DEPRECATIONS_ENABLED: "true" + ENABLED_DEPRECATIONS: "true" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins" - name: "All deprecations enabled, with optional features" - ALL_DEPRECATIONS_ENABLED: "true" - ENABLE_OPTIONAL_FEATURES: "true" - - name: "Available deprecations enabled via stage config" ENABLED_DEPRECATIONS: "true" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins" + ENABLE_OPTIONAL_FEATURES: "true" - name: "Deprecation compliance declared" DEPRECATION_COMPLIANCE: "7.2.0" RAISE_ON_DEPRECATION: "false" @@ -131,8 +135,8 @@ jobs: NODE_ENV: ${{ matrix.BUILD || 'development' }} - name: test env: - ALL_DEPRECATIONS_ENABLED: ${{ matrix.ALL_DEPRECATIONS_ENABLED }} ENABLED_DEPRECATIONS: ${{ matrix.ENABLED_DEPRECATIONS }} + EXCEPT_DEPRECATIONS: ${{ matrix.EXCEPT_DEPRECATIONS }} DEPRECATION_COMPLIANCE: ${{ matrix.DEPRECATION_COMPLIANCE }} OVERRIDE_DEPRECATION_VERSION: ${{ matrix.OVERRIDE_DEPRECATION_VERSION }} ENABLE_OPTIONAL_FEATURES: ${{ matrix.ENABLE_OPTIONAL_FEATURES }} diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 36288256106..7d9b5556bf7 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -174,6 +174,32 @@ export const DEPRECATIONS = { }, DEPRECATE_COMPARABLE_MIXIN ), + // The classic object model deprecations have no @ember/deprecated-features + // flags: their machinery cannot be tree-shaken in-module while ember's own + // base classes are built with the internal aliases (internalExtend, + // createMixin, ...). Removing the machinery is the modern build variant's + // module-swap job. + DEPRECATE_EMBER_OBJECT_EXTEND: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-object-extend', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-object-extend', + }), + DEPRECATE_EMBER_OBJECT_REOPEN: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-object-reopen', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-object-reopen', + }), + DEPRECATE_EMBER_MIXINS: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-mixins', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-mixins', + }), }; export function deprecateUntil(message: string, deprecation: DeprecationObject) { diff --git a/packages/@ember/object/core.ts b/packages/@ember/object/core.ts index febf4185d16..f20372361bc 100644 --- a/packages/@ember/object/core.ts +++ b/packages/@ember/object/core.ts @@ -15,6 +15,7 @@ import { defineProperty } from '@ember/-internals/metal/lib/properties'; import { descriptorForProperty, isClassicDecorator } from '@ember/-internals/metal/lib/decorator'; import { DEBUG_INJECTION_FUNCTIONS } from '@ember/-internals/metal/lib/injected_property'; import Mixin, { applyMixin, createMixin } from '@ember/object/mixin'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import makeArray from '@ember/array/make'; import { assert } from '@ember/debug'; @@ -712,6 +713,10 @@ class CoreObject { ...mixins: M ): Readonly & EmberClassConstructor & MergeArray; static extend(this: typeof CoreObject, ...mixins: any[]) { + deprecateUntil( + 'The classic class definition API `.extend()` is deprecated. Convert to a native class, for example with the ember-native-class-codemod.', + DEPRECATIONS.DEPRECATE_EMBER_OBJECT_EXTEND + ); return internalExtend(this, ...mixins); } @@ -835,6 +840,10 @@ class CoreObject { @public */ static reopen(this: C, ...args: any[]): C { + deprecateUntil( + 'The classic class API `.reopen()` is deprecated. Define the properties and methods on a native class (or a subclass) instead.', + DEPRECATIONS.DEPRECATE_EMBER_OBJECT_REOPEN + ); return internalReopen(this, ...args); } @@ -917,6 +926,10 @@ class CoreObject { this: C, ...mixins: Array> ): C { + deprecateUntil( + 'The classic class API `.reopenClass()` is deprecated. Define static properties and methods on a native class (or a subclass) instead.', + DEPRECATIONS.DEPRECATE_EMBER_OBJECT_REOPEN + ); return internalReopenClass(this, ...mixins); } diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index 258e190d919..2dd540bc0de 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -6,6 +6,7 @@ import type { Meta } from '@ember/-internals/meta/lib/meta'; import { meta as metaFor, peekMeta } from '@ember/-internals/meta/lib/meta'; import { observerListenerMetaFor, ROOT, wrap } from '@ember/-internals/utils/lib/super'; import { assert } from '@ember/debug'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import { DEBUG } from '@glimmer/env'; import type { ComputedDecorator, @@ -577,6 +578,10 @@ export default class Mixin { @public */ static create(...args: any[]): InstanceType { + deprecateUntil( + 'Ember Mixins are deprecated. Replace mixin usage with native class composition (utility functions, delegation, or base classes).', + DEPRECATIONS.DEPRECATE_EMBER_MIXINS + ); setUnprocessedMixins(); let M = this; return new M(args, undefined) as InstanceType; diff --git a/packages/@ember/object/tests/classic-object-model-deprecations-test.js b/packages/@ember/object/tests/classic-object-model-deprecations-test.js new file mode 100644 index 00000000000..57c3751b723 --- /dev/null +++ b/packages/@ember/object/tests/classic-object-model-deprecations-test.js @@ -0,0 +1,139 @@ +import EmberObject from '@ember/object'; +import { internalExtend, internalReopen, internalReopenClass } from '@ember/object/core'; +import Mixin, { createMixin } from '@ember/object/mixin'; +import Application from '@ember/application'; +import EmberRouter from '@ember/routing/router'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { + moduleForDevelopment, + AbstractTestCase, + ModuleBasedTestResolver, + runTask, +} from 'internal-test-helpers'; + +const CLASSIC_IDS = [ + 'deprecate-ember-object-extend', + 'deprecate-ember-object-reopen', + 'deprecate-ember-mixins', +]; + +moduleForDevelopment( + 'classic object model deprecations', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test the deprecations are available-stage: nothing fires by default'](assert) { + expectNoDeprecation(() => { + let Klass = EmberObject.extend({ someProp: 'value' }); + Klass.reopen({ otherProp: 'value' }); + Klass.reopenClass({ staticProp: 'value' }); + Mixin.create({ mixedIn: 'value' }); + }); + assert.ok(true, 'no deprecations fired'); + } + + ['@test extend fires when enabled']() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectDeprecation(() => { + EmberObject.extend({ someProp: 'value' }); + }, /The classic class definition API `\.extend\(\)` is deprecated/); + } + + ['@test extend fires for subclasses created with extend']() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + let Klass; + expectDeprecation(() => { + Klass = EmberObject.extend(); + }, /`\.extend\(\)` is deprecated/); + + expectDeprecation(() => { + Klass.extend(); + }, /`\.extend\(\)` is deprecated/); + } + + ['@test reopen and reopenClass fire when enabled']() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + class Klass extends EmberObject {} + + expectDeprecation(() => { + Klass.reopen({ someProp: 'value' }); + }, /The classic class API `\.reopen\(\)` is deprecated/); + + expectDeprecation(() => { + Klass.reopenClass({ staticProp: 'value' }); + }, /The classic class API `\.reopenClass\(\)` is deprecated/); + } + + ['@test Mixin.create fires when enabled']() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectDeprecation(() => { + Mixin.create({ mixedIn: 'value' }); + }, /Ember Mixins are deprecated/); + } + + ['@test the internal aliases never fire'](assert) { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectNoDeprecation(() => { + let Klass = internalExtend(EmberObject, { someProp: 'value' }); + internalReopen(Klass, { otherProp: 'value' }); + internalReopenClass(Klass, { staticProp: 'value' }); + createMixin({ mixedIn: 'value' }); + // instantiation applies the pending mixins (proto/applyMixin) and + // must not fire either + Klass.create().destroy(); + }); + assert.ok(true, 'no deprecations fired'); + } + } +); + +moduleForDevelopment( + 'classic object model deprecations: framework runtime paths', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + if (this.app) { + runTask(() => this.app.destroy()); + } + } + + // The regression net for ember's internal runtime uses of the classic + // definition APIs: booting an app (autoboot re-extends the Router + // internally), Router.map (reopenClass on the app's router), and + // initializer registration (reopenClass on the app class) must not be + // blamed on the app. + ['@test autoboot, Router.map, and initializers fire nothing when enabled'](assert) { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectNoDeprecation(() => { + class TestRouter extends EmberRouter {} + TestRouter.map(function () { + this.route('example'); + }); + + class TestApplication extends Application {} + TestApplication.initializer({ + name: 'test-initializer', + initialize() {}, + }); + + this.app = runTask(() => + TestApplication.create({ + rootElement: '#qunit-fixture', + autoboot: true, + router: null, + Resolver: ModuleBasedTestResolver, + }) + ); + }); + assert.ok(true, 'no deprecations fired'); + } + } +); From 2cd228bfe9e3af02902ff535bae6e707f8aaac2c Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:50:25 -0700 Subject: [PATCH 15/24] Deprecate computed properties and observers (available stage) deprecate-computed-properties fires from the public computed() wrapper and once at module eval of the @ember/object/computed macros barrel; deprecate-observers fires from observer() and the public addObserver/removeObserver. Framework internals use the metal modules directly (the two remaining public-barrel imports in the routing services moved to the deep path) and stay silent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 4 +- .../@ember/-internals/deprecations/index.ts | 14 +++ packages/@ember/object/computed.ts | 9 ++ packages/@ember/object/index.ts | 18 ++- packages/@ember/object/observers.ts | 25 ++++- .../computed-observers-deprecations-test.js | 103 ++++++++++++++++++ .../@ember/routing/lib/routing-service.ts | 2 +- packages/@ember/routing/router-service.ts | 2 +- 8 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 packages/@ember/object/tests/computed-observers-deprecations-test.js diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index 506c00a9ef2..9c2b4a538b3 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -105,10 +105,10 @@ jobs: # coverage in packages/@ember/object/tests instead. - name: "All deprecations enabled" ENABLED_DEPRECATIONS: "true" - EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers" - name: "All deprecations enabled, with optional features" ENABLED_DEPRECATIONS: "true" - EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers" ENABLE_OPTIONAL_FEATURES: "true" - name: "Deprecation compliance declared" DEPRECATION_COMPLIANCE: "7.2.0" diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 7d9b5556bf7..1dc41da1ae7 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -200,6 +200,20 @@ export const DEPRECATIONS = { until: '8.0.0', url: 'https://deprecations.emberjs.com/id/deprecate-ember-mixins', }), + DEPRECATE_COMPUTED_PROPERTIES: deprecation({ + for: 'ember-source', + id: 'deprecate-computed-properties', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-computed-properties', + }), + DEPRECATE_OBSERVERS: deprecation({ + for: 'ember-source', + id: 'deprecate-observers', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-observers', + }), }; export function deprecateUntil(message: string, deprecation: DeprecationObject) { diff --git a/packages/@ember/object/computed.ts b/packages/@ember/object/computed.ts index 5e4d41bc33b..ec1544f720a 100644 --- a/packages/@ember/object/computed.ts +++ b/packages/@ember/object/computed.ts @@ -1,3 +1,12 @@ +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; + +// Fires once per app load, when anything imports this module: the whole +// computed-property macro surface is deprecated together. +deprecateUntil( + 'Importing from `@ember/object/computed` is deprecated. Computed property macros are part of the classic object model; replace them with `@tracked` properties and native getters (with `@cached` where memoization is needed).', + DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES +); + export { ComputedProperty as default } from '@ember/-internals/metal/lib/computed'; export { default as expandProperties } from '@ember/-internals/metal/lib/expand_properties'; export { default as alias } from '@ember/-internals/metal/lib/alias'; diff --git a/packages/@ember/object/index.ts b/packages/@ember/object/index.ts index 3b74a20cf01..fce3032f82e 100644 --- a/packages/@ember/object/index.ts +++ b/packages/@ember/object/index.ts @@ -11,6 +11,8 @@ import { setObservers } from '@ember/-internals/utils/lib/super'; import type { AnyFn } from '@ember/-internals/utility-types'; import CoreObject, { internalExtend } from '@ember/object/core'; import Observable from '@ember/object/observable'; +import metalComputed from '@ember/-internals/metal/lib/computed'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; export { notifyPropertyChange } from '@ember/-internals/metal/lib/property_events'; export { defineProperty } from '@ember/-internals/metal/lib/properties'; @@ -18,7 +20,16 @@ export { get } from '@ember/-internals/metal/lib/property_get'; export { set, trySet } from '@ember/-internals/metal/lib/property_set'; export { default as getProperties } from '@ember/-internals/metal/lib/get_properties'; export { default as setProperties } from '@ember/-internals/metal/lib/set_properties'; -export { default as computed } from '@ember/-internals/metal/lib/computed'; + +// Deprecating wrapper: ember-source's own uses go through the metal module +// directly and stay silent. +export const computed = ((...args: Parameters) => { + deprecateUntil( + 'Computed properties are deprecated. Replace `computed()` with `@tracked` properties and native getters (with `@cached` where memoization is needed).', + DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES + ); + return metalComputed(...args); +}) as typeof metalComputed; /** @module @ember/object @@ -269,6 +280,11 @@ export function observer( | [propertyName: string, ...additionalPropertyNames: string[], func: T] | [ObserverDefinition] ): T { + deprecateUntil( + 'Observers are deprecated. Derive state with `@tracked` properties and native getters, or react to changes explicitly.', + DEPRECATIONS.DEPRECATE_OBSERVERS + ); + let funcOrDef = args.pop(); assert( diff --git a/packages/@ember/object/observers.ts b/packages/@ember/object/observers.ts index 595cec5fd1a..0dfe9771396 100644 --- a/packages/@ember/object/observers.ts +++ b/packages/@ember/object/observers.ts @@ -1 +1,24 @@ -export { addObserver, removeObserver } from '@ember/-internals/metal/lib/observer'; +import { + addObserver as metalAddObserver, + removeObserver as metalRemoveObserver, +} from '@ember/-internals/metal/lib/observer'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; + +// Deprecating wrappers: ember-source's own uses go through the metal module +// directly and stay silent. + +export const addObserver = ((...args: Parameters) => { + deprecateUntil( + 'Observers are deprecated. Derive state with `@tracked` properties and native getters, or react to changes explicitly.', + DEPRECATIONS.DEPRECATE_OBSERVERS + ); + return metalAddObserver(...args); +}) as typeof metalAddObserver; + +export const removeObserver = ((...args: Parameters) => { + deprecateUntil( + 'Observers are deprecated. Derive state with `@tracked` properties and native getters, or react to changes explicitly.', + DEPRECATIONS.DEPRECATE_OBSERVERS + ); + return metalRemoveObserver(...args); +}) as typeof metalRemoveObserver; diff --git a/packages/@ember/object/tests/computed-observers-deprecations-test.js b/packages/@ember/object/tests/computed-observers-deprecations-test.js new file mode 100644 index 00000000000..e66c1234f11 --- /dev/null +++ b/packages/@ember/object/tests/computed-observers-deprecations-test.js @@ -0,0 +1,103 @@ +import EmberObject, { computed, observer } from '@ember/object'; +import { addObserver, removeObserver } from '@ember/object/observers'; +import metalComputed from '@ember/-internals/metal/lib/computed'; +import { + addObserver as metalAddObserver, + removeObserver as metalRemoveObserver, +} from '@ember/-internals/metal/lib/observer'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { moduleForDevelopment, AbstractTestCase, runLoopSettled } from 'internal-test-helpers'; + +const IDS = ['deprecate-computed-properties', 'deprecate-observers']; + +// The module-eval deprecation on the `@ember/object/computed` macros barrel +// cannot be asserted here: the module already evaluated (silently, since +// these ids are available-stage and off by default) when the suite loaded. +// It is exercised by any consumer that imports the barrel with the id +// enabled at boot. +moduleForDevelopment( + 'computed property and observer deprecations', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test nothing fires by default'](assert) { + expectNoDeprecation(() => { + let obj = EmberObject.create({ first: 'a' }); + let handler = () => {}; + computed('first', function () { + return this.first; + }); + observer('first', function () {}); + addObserver(obj, 'first', null, handler, true); + removeObserver(obj, 'first', null, handler, true); + obj.destroy(); + }); + assert.ok(true, 'no deprecations fired'); + } + + ['@test computed() fires when enabled and still works'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let cp; + expectDeprecation(() => { + cp = computed('first', function () { + return `${this.first}!`; + }); + }, /Computed properties are deprecated/); + + // extend stays silent here: deprecate-ember-object-extend is not in + // the enabled set for this module + let Klass = EmberObject.extend({ first: 'a', shouted: cp }); + let obj = Klass.create(); + assert.strictEqual(obj.get('shouted'), 'a!', 'the computed property works'); + obj.destroy(); + } + + ['@test observer() fires when enabled']() { + setDeprecationStagesConfig({ enable: IDS }); + + expectDeprecation(() => { + observer('first', function () {}); + }, /Observers are deprecated/); + } + + ['@test addObserver and removeObserver fire when enabled'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let obj = EmberObject.create({ first: 'a' }); + let handler = () => {}; + + expectDeprecation(() => { + addObserver(obj, 'first', null, handler, true); + }, /Observers are deprecated/); + + expectDeprecation(() => { + removeObserver(obj, 'first', null, handler, true); + }, /Observers are deprecated/); + + obj.destroy(); + assert.ok(true, 'ran without throwing'); + } + + async ['@test the internal metal paths never fire'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let obj = EmberObject.create({ first: 'a' }); + let handler = () => {}; + + expectNoDeprecation(() => { + metalComputed('first', function () { + return this.first; + }); + metalAddObserver(obj, 'first', null, handler, true); + metalRemoveObserver(obj, 'first', null, handler, true); + }); + + obj.destroy(); + await runLoopSettled(); + assert.ok(true, 'no deprecations fired'); + } + } +); diff --git a/packages/@ember/routing/lib/routing-service.ts b/packages/@ember/routing/lib/routing-service.ts index 10cd5b848a4..2e8569c52d9 100644 --- a/packages/@ember/routing/lib/routing-service.ts +++ b/packages/@ember/routing/lib/routing-service.ts @@ -4,7 +4,7 @@ import { getOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; -import { readOnly } from '@ember/object/computed'; +import { readOnly } from '@ember/object/lib/computed/computed_macros'; import { internalReopen } from '@ember/object/core'; import Service from '@ember/service'; import type { ModelFor } from 'router_js'; diff --git a/packages/@ember/routing/router-service.ts b/packages/@ember/routing/router-service.ts index 50ebcb8626b..cd0f6161d30 100644 --- a/packages/@ember/routing/router-service.ts +++ b/packages/@ember/routing/router-service.ts @@ -5,7 +5,7 @@ import { getOwner } from '@ember/-internals/owner'; import Evented from '@ember/object/evented'; import { internalExtend } from '@ember/object/core'; import { assert } from '@ember/debug'; -import { readOnly } from '@ember/object/computed'; +import { readOnly } from '@ember/object/lib/computed/computed_macros'; import Service from '@ember/service'; import { consumeTag } from '@glimmer/validator/lib/tracking'; import { tagFor } from '@glimmer/validator/lib/meta'; From 097e23e837190ef5a04efc3533b1f5f9af18ee88 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:54:32 -0700 Subject: [PATCH 16/24] Deprecate A() (available stage) deprecate-ember-array fires from the public A(); internals use the new internalA (data-adapter, reduce_computed_macros, route/router QP handling). The EmberArray/MutableArray/NativeArray mixins ride the extend/mixins deprecations when applied externally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 4 +- .../@ember/-internals/deprecations/index.ts | 7 ++++ packages/@ember/array/index.ts | 29 ++++++++++---- .../@ember/array/tests/a-deprecation-test.js | 39 +++++++++++++++++++ packages/@ember/debug/data-adapter.ts | 2 +- .../lib/computed/reduce_computed_macros.ts | 2 +- packages/@ember/routing/route.ts | 2 +- packages/@ember/routing/router.ts | 2 +- 8 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 packages/@ember/array/tests/a-deprecation-test.js diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index 9c2b4a538b3..ca06d68a8d4 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -105,10 +105,10 @@ jobs: # coverage in packages/@ember/object/tests instead. - name: "All deprecations enabled" ENABLED_DEPRECATIONS: "true" - EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array" - name: "All deprecations enabled, with optional features" ENABLED_DEPRECATIONS: "true" - EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array" ENABLE_OPTIONAL_FEATURES: "true" - name: "Deprecation compliance declared" DEPRECATION_COMPLIANCE: "7.2.0" diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 1dc41da1ae7..637312de337 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -214,6 +214,13 @@ export const DEPRECATIONS = { until: '8.0.0', url: 'https://deprecations.emberjs.com/id/deprecate-observers', }), + DEPRECATE_EMBER_ARRAY: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-array', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-array', + }), }; export function deprecateUntil(message: string, deprecation: DeprecationObject) { diff --git a/packages/@ember/array/index.ts b/packages/@ember/array/index.ts index 2901866ed35..2975ef57952 100644 --- a/packages/@ember/array/index.ts +++ b/packages/@ember/array/index.ts @@ -12,6 +12,7 @@ import { get } from '@ember/-internals/metal/lib/property_get'; import { set } from '@ember/-internals/metal/lib/property_set'; import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import Enumerable from '@ember/enumerable'; import MutableEnumerable from '@ember/enumerable/mutable'; import compare from '@ember/utils/lib/compare'; @@ -2049,14 +2050,13 @@ NativeArray.keys().forEach((methodName) => { NativeArray = NativeArray.without(...ignore); -let A: (arr?: Array) => NativeArray; - -A = function (this: unknown, arr?: Array) { - assert( - 'You cannot create an Ember Array with `new A()`, please update to calling A as a function: `A()`', - !(this instanceof A) - ); +/** + Non-deprecating equivalent of `A()` for ember-source's own internals. + External code must use `A()`. + @internal +*/ +export function internalA(arr?: Array): NativeArray { if (isEmberArray(arr)) { // SAFETY: If it's a true native array and it is also an EmberArray then it should be an Ember NativeArray return arr as unknown as NativeArray; @@ -2064,6 +2064,21 @@ A = function (this: unknown, arr?: Array) { // SAFETY: This will return an NativeArray but TS can't infer that. return NativeArray.apply(arr ?? []) as NativeArray; } +} + +let A: (arr?: Array) => NativeArray; + +A = function (this: unknown, arr?: Array) { + assert( + 'You cannot create an Ember Array with `new A()`, please update to calling A as a function: `A()`', + !(this instanceof A) + ); + deprecateUntil( + 'Ember Arrays are deprecated. Use native arrays (replacing them on change), or tracked collections from tracked-built-ins.', + DEPRECATIONS.DEPRECATE_EMBER_ARRAY + ); + + return internalA(arr); }; export { A, NativeArray, MutableArray }; diff --git a/packages/@ember/array/tests/a-deprecation-test.js b/packages/@ember/array/tests/a-deprecation-test.js new file mode 100644 index 00000000000..be86bfe358d --- /dev/null +++ b/packages/@ember/array/tests/a-deprecation-test.js @@ -0,0 +1,39 @@ +import { A, internalA } from '@ember/array'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { moduleForDevelopment, AbstractTestCase } from 'internal-test-helpers'; + +moduleForDevelopment( + 'Ember Array deprecation', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test A() is silent by default'](assert) { + expectNoDeprecation(() => { + A(['a', 'b']); + }); + assert.ok(true, 'no deprecations fired'); + } + + ['@test A() fires when enabled and still works'](assert) { + setDeprecationStagesConfig({ enable: ['deprecate-ember-array'] }); + + let arr; + expectDeprecation(() => { + arr = A(['a', 'b']); + }, /Ember Arrays are deprecated/); + + assert.strictEqual(arr.objectAt(1), 'b', 'the array works'); + } + + ['@test internalA never fires'](assert) { + setDeprecationStagesConfig({ enable: ['deprecate-ember-array'] }); + + expectNoDeprecation(() => { + internalA(['a', 'b']); + }); + assert.ok(true, 'no deprecations fired'); + } + } +); diff --git a/packages/@ember/debug/data-adapter.ts b/packages/@ember/debug/data-adapter.ts index 4b37faaff4b..dc0ac757de2 100644 --- a/packages/@ember/debug/data-adapter.ts +++ b/packages/@ember/debug/data-adapter.ts @@ -6,7 +6,7 @@ import { dasherize } from '@ember/-internals/string'; import Namespace from '@ember/application/namespace'; import type { NativeArray } from '@ember/array'; import EmberObject from '@ember/object'; -import { A as emberA } from '@ember/array'; +import { internalA as emberA } from '@ember/array'; import type { Cache } from '@glimmer/validator/lib/tracking'; import { consumeTag, createCache, getValue, untrack } from '@glimmer/validator/lib/tracking'; import { tagFor } from '@glimmer/validator/lib/meta'; diff --git a/packages/@ember/object/lib/computed/reduce_computed_macros.ts b/packages/@ember/object/lib/computed/reduce_computed_macros.ts index a08f1953b3d..fb73e98fc8c 100644 --- a/packages/@ember/object/lib/computed/reduce_computed_macros.ts +++ b/packages/@ember/object/lib/computed/reduce_computed_macros.ts @@ -8,7 +8,7 @@ import { isElementDescriptor } from '@ember/-internals/metal/lib/decorator'; import computed from '@ember/-internals/metal/lib/computed'; import { get } from '@ember/-internals/metal/lib/property_get'; import compare from '@ember/utils/lib/compare'; -import EmberArray, { A as emberA, uniqBy as uniqByArray } from '@ember/array'; +import EmberArray, { internalA as emberA, uniqBy as uniqByArray } from '@ember/array'; import type { NativeArray } from '@ember/array'; function isNativeOrEmberArray(obj: unknown): obj is unknown[] | EmberArray { diff --git a/packages/@ember/routing/route.ts b/packages/@ember/routing/route.ts index dc5570ce750..66ea1cafce2 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -13,7 +13,7 @@ import setProperties from '@ember/-internals/metal/lib/set_properties'; import EmberObject from '@ember/object'; import { internalExtend, internalReopen } from '@ember/object/core'; import Evented from '@ember/object/evented'; -import { A as emberA } from '@ember/array'; +import { internalA as emberA } from '@ember/array'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import typeOf from '@ember/utils/lib/type-of'; import { isProxy } from '@ember/-internals/utils/lib/is_proxy'; diff --git a/packages/@ember/routing/router.ts b/packages/@ember/routing/router.ts index 86615738471..9188b75edb3 100644 --- a/packages/@ember/routing/router.ts +++ b/packages/@ember/routing/router.ts @@ -28,7 +28,7 @@ import type { import type RouterService from '@ember/routing/router-service'; import EmberObject from '@ember/object'; import { internalExtend, internalReopen, internalReopenClass } from '@ember/object/core'; -import { A as emberA } from '@ember/array'; +import { internalA as emberA } from '@ember/array'; import typeOf from '@ember/utils/lib/type-of'; import Evented from '@ember/object/evented'; import { assert, info } from '@ember/debug'; From e0a3d5256fbabbb84a373a9610570edaeb4f1bd7 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:58:27 -0700 Subject: [PATCH 17/24] Deprecate ObjectProxy and ArrayProxy (available stage) Fires at init, deduped by a WeakSet on the constructor so high-volume proxy creation warns once per class. Internals never instantiate these. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 4 +- .../@ember/-internals/deprecations/index.ts | 14 ++++ packages/@ember/array/proxy.ts | 13 ++++ packages/@ember/object/proxy.ts | 19 +++++- .../object/tests/proxy-deprecations-test.js | 67 +++++++++++++++++++ 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 packages/@ember/object/tests/proxy-deprecations-test.js diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index ca06d68a8d4..a026227480e 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -105,10 +105,10 @@ jobs: # coverage in packages/@ember/object/tests instead. - name: "All deprecations enabled" ENABLED_DEPRECATIONS: "true" - EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" - name: "All deprecations enabled, with optional features" ENABLED_DEPRECATIONS: "true" - EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" ENABLE_OPTIONAL_FEATURES: "true" - name: "Deprecation compliance declared" DEPRECATION_COMPLIANCE: "7.2.0" diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 637312de337..96cb9158778 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -221,6 +221,20 @@ export const DEPRECATIONS = { until: '8.0.0', url: 'https://deprecations.emberjs.com/id/deprecate-ember-array', }), + DEPRECATE_OBJECT_PROXY: deprecation({ + for: 'ember-source', + id: 'deprecate-object-proxy', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-object-proxy', + }), + DEPRECATE_ARRAY_PROXY: deprecation({ + for: 'ember-source', + id: 'deprecate-array-proxy', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-array-proxy', + }), }; export function deprecateUntil(message: string, deprecation: DeprecationObject) { diff --git a/packages/@ember/array/proxy.ts b/packages/@ember/array/proxy.ts index 331cb4248cb..ee7babc86d9 100644 --- a/packages/@ember/array/proxy.ts +++ b/packages/@ember/array/proxy.ts @@ -16,6 +16,7 @@ import type { PropertyDidChange } from '@ember/-internals/metal/lib/property_eve import { isObject } from '@ember/-internals/utils/lib/spec'; import EmberObject from '@ember/object'; import { internalReopen } from '@ember/object/core'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import EmberArray, { type NativeArray } from '@ember/array'; import MutableArray from '@ember/array/mutable'; import { assert } from '@ember/debug'; @@ -115,6 +116,10 @@ function customTagForArrayProxy(proxy: object, key: string) { @uses MutableArray @public */ +// Dedupe: the deprecation fires once per ArrayProxy subclass, not once per +// instance — proxies can be created in volume. +const deprecatedClasses = new WeakSet(); + interface ArrayProxy extends MutableArray { /** The content array. Must be an object that implements `Array` and/or @@ -202,6 +207,14 @@ class ArrayProxy extends EmberObject implements PropertyDidChange { init(props: object | undefined) { super.init(props); + if (!deprecatedClasses.has(this.constructor)) { + deprecatedClasses.add(this.constructor); + deprecateUntil( + 'ArrayProxy is deprecated. Use a native array, or a tracked collection from tracked-built-ins, and expose derived state with native getters.', + DEPRECATIONS.DEPRECATE_ARRAY_PROXY + ); + } + setCustomTagFor(this, customTagForArrayProxy); } diff --git a/packages/@ember/object/proxy.ts b/packages/@ember/object/proxy.ts index d561d7777df..b6aab2cd8ff 100644 --- a/packages/@ember/object/proxy.ts +++ b/packages/@ember/object/proxy.ts @@ -4,6 +4,7 @@ import { FrameworkObject } from '@ember/object/-internals'; import _ProxyMixin from '@ember/-internals/runtime/lib/mixins/-proxy'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; /** `ObjectProxy` forwards all properties not defined by the proxy itself @@ -119,8 +120,24 @@ interface ObjectProxy extends _ProxyMixin { setProperties>(hash: T): T; } +// Dedupe: the deprecation fires once per ObjectProxy subclass, not once per +// instance — proxies can be created in volume. +const deprecatedClasses = new WeakSet(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -class ObjectProxy extends FrameworkObject {} +class ObjectProxy extends FrameworkObject { + init(properties: object | undefined) { + super.init(properties); + + if (!deprecatedClasses.has(this.constructor)) { + deprecatedClasses.add(this.constructor); + deprecateUntil( + 'ObjectProxy is deprecated. Access the underlying object directly, or expose derived state with native getters.', + DEPRECATIONS.DEPRECATE_OBJECT_PROXY + ); + } + } +} ObjectProxy.PrototypeMixin.reopen(_ProxyMixin); export default ObjectProxy; diff --git a/packages/@ember/object/tests/proxy-deprecations-test.js b/packages/@ember/object/tests/proxy-deprecations-test.js new file mode 100644 index 00000000000..4c85e002e8b --- /dev/null +++ b/packages/@ember/object/tests/proxy-deprecations-test.js @@ -0,0 +1,67 @@ +import ObjectProxy from '@ember/object/proxy'; +import ArrayProxy from '@ember/array/proxy'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { moduleForDevelopment, AbstractTestCase } from 'internal-test-helpers'; + +const IDS = ['deprecate-object-proxy', 'deprecate-array-proxy']; + +moduleForDevelopment( + 'ObjectProxy and ArrayProxy deprecations', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test proxies are silent by default'](assert) { + expectNoDeprecation(() => { + ObjectProxy.create({ content: { name: 'foo' } }).destroy(); + ArrayProxy.create({ content: ['a'] }).destroy(); + }); + assert.ok(true, 'no deprecations fired'); + } + + ['@test ObjectProxy fires once per class when enabled'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + class ProxyA extends ObjectProxy {} + class ProxyB extends ObjectProxy {} + + let first; + expectDeprecation(() => { + first = ProxyA.create({ content: { name: 'foo' } }); + }, /ObjectProxy is deprecated/); + + let second; + expectNoDeprecation(() => { + second = ProxyA.create({ content: { name: 'bar' } }); + }); + + let third; + expectDeprecation(() => { + third = ProxyB.create({ content: { name: 'baz' } }); + }, /ObjectProxy is deprecated/); + + assert.strictEqual(first.get('name'), 'foo', 'the proxy works'); + [first, second, third].forEach((proxy) => proxy.destroy()); + } + + ['@test ArrayProxy fires once per class when enabled'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + class ProxyA extends ArrayProxy {} + + let first; + expectDeprecation(() => { + first = ProxyA.create({ content: ['a', 'b'] }); + }, /ArrayProxy is deprecated/); + + let second; + expectNoDeprecation(() => { + second = ProxyA.create({ content: ['c'] }); + }); + + assert.strictEqual(first.objectAt(1), 'b', 'the proxy works'); + [first, second].forEach((proxy) => proxy.destroy()); + } + } +); From cc22e7510cc5d3cf2e8d262eaac7107583be9f22 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 16:00:47 -0700 Subject: [PATCH 18/24] Document the classic object model deprecation wave RFC appendix covering the wave-1 ids, the internal-alias approach, why these entries have no shaking flags, and the blanket-CI except-list rationale; registry doc comment gains the internal-alias convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...eprecation-early-enablement-and-shaking.md | 49 +++++++++++++++++++ .../@ember/-internals/deprecations/index.ts | 13 +++++ 2 files changed, 62 insertions(+) diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index 545aa07e7da..607ac1d8703 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -317,6 +317,55 @@ and is deleted at the next major regardless. monotonic "compliant through X" declaration RFC 0649 designed for, where upgrading ember-source never silently reduces your protection. +## Appendix: wave 1 — classic object model deprecations + +The first consumer of the early-enablement workflow is the classic object +model, matching what the modern build variant removes. All entries are +available-stage (`since: { available: '7.3.0' }`, no `enabled`), `until: +'8.0.0'`: + +| id | fires from | replacement | +|---|---|---| +| `deprecate-ember-object-extend` | the public static `.extend()` | native classes (ember-native-class-codemod) | +| `deprecate-ember-object-reopen` | the public statics `.reopen()` / `.reopenClass()` | subclassing / module refactor | +| `deprecate-ember-mixins` | `Mixin.create` | native class composition | +| `deprecate-computed-properties` | the public `computed()` and module eval of `@ember/object/computed` | `@tracked` + native getters, `@cached` | +| `deprecate-observers` | `observer()`, public `addObserver`/`removeObserver` | derived state / explicit events | +| `deprecate-ember-array` | `A()` | native arrays / tracked-built-ins | +| `deprecate-object-proxy` | `ObjectProxy` init (once per class) | direct access / native getters | +| `deprecate-array-proxy` | `ArrayProxy` init (once per class) | tracked-built-ins | + +Design notes: + +- **Internal aliases.** Ember's own framework hierarchy is built with these + exact APIs at module eval (`EmberObject` itself is + `CoreObject.extend(Observable)`), and internals reach them through the same + public modules external code uses — import-path separation is impossible + for `extend`/`reopen`/`Mixin.create`. Framework definitions therefore go + through internal non-deprecating entry points (`internalExtend`, + `internalReopen`, `internalReopenClass` in `@ember/object/core`; + `createMixin` in `@ember/object/mixin`; `internalA` in `@ember/array`; the + metal modules for `computed`/observers), following the `metalInject` + precedent. This includes the runtime paths (autoboot's Router re-extend, + `Router.map`'s `reopenClass`, engine initializer registration) so + framework operation is never blamed on the app. +- **No shaking flags.** The classic-class machinery cannot be tree-shaken + in-module while ember's own base classes are built with the internal + aliases; removing it is the modern build variant's module-swap job. These + are plain registry entries. +- **`Evented` and the array mixins have no separate ids**: applying them + externally goes through `.extend()`/`Mixin.create` and rides those ids. +- **Blanket enablement excludes these ids.** Ember's own suite and + internal-test-helpers exercise the classic APIs pervasively, so the + all-available-deprecations CI rows run with these ids in `except` — the + first real use of the `except`-excludes-`enable` semantics. Targeted test + modules opt in per-id instead. This also documents the migration reality + for apps: `enable: true` is unrealistic for classic-object-model codebases; + per-id adoption is the intended path. +- **Observer deprecation aligns with RFC PR #1115**, which proposes + deprecating observers; this implementation gives that RFC its + available-stage mechanism. + ## Unresolved questions - **Should `compliance` also cover available-stage ids the app opted into diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 96cb9158778..c5137b55c54 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -138,6 +138,19 @@ export function deprecation(options: DeprecationOptions, flag?: boolean): Deprec the throwing stub while the guarded implementation is eliminated. In a build where the flag is false, the registry entry reports `isRemoved`, so any reach of the API throws the removal error. + + ## Deprecating APIs ember-source itself uses + + When the deprecated API is also used by ember-source's own framework code + (and internal use cannot be separated by import path), give the internals a + non-deprecating entry point and put the deprecateUntil call only in the + public one. The reference pattern is `inject` (public wrapper in + @ember/service; internals call metal's `injected_property` directly). + Other examples: `internalExtend`/`internalReopen`/`internalReopenClass` + (@ember/object/core), `createMixin` (@ember/object/mixin), `internalA` + (@ember/array). Include a regression test proving framework operation + (module eval, boot, runtime paths) fires nothing with the deprecation + enabled. */ export const DEPRECATIONS = { DEPRECATE_IMPORT_EMBER(importName: string) { From 2a7961db57b56b69f231e566c9e802acb7683e7b Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 16:26:00 -0700 Subject: [PATCH 19/24] Fire the computed-macro deprecation per call, not at barrel eval Consumer bundlers treat @ember/object/computed as side-effect-free (the package-level sideEffects declaration) and drop top-level statements when only re-exports are used, so the module-eval deprecation never fired in bundled apps. Each macro export is now a call-time deprecating wrapper; internals keep using the underlying lib modules directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@ember/object/computed.ts | 128 ++++++++++++------ .../computed-observers-deprecations-test.js | 31 ++++- 2 files changed, 111 insertions(+), 48 deletions(-) diff --git a/packages/@ember/object/computed.ts b/packages/@ember/object/computed.ts index ec1544f720a..d820ba487cc 100644 --- a/packages/@ember/object/computed.ts +++ b/packages/@ember/object/computed.ts @@ -1,49 +1,91 @@ import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; +import type { AnyFn } from '@ember/-internals/utility-types'; +import metalExpandProperties from '@ember/-internals/metal/lib/expand_properties'; +import metalAlias from '@ember/-internals/metal/lib/alias'; +import { + empty as _empty, + notEmpty as _notEmpty, + none as _none, + not as _not, + bool as _bool, + match as _match, + equal as _equal, + gt as _gt, + gte as _gte, + lt as _lt, + lte as _lte, + oneWay as _oneWay, + readOnly as _readOnly, + deprecatingAlias as _deprecatingAlias, + and as _and, + or as _or, +} from './lib/computed/computed_macros'; +import { + sum as _sum, + min as _min, + max as _max, + map as _map, + sort as _sort, + setDiff as _setDiff, + mapBy as _mapBy, + filter as _filter, + filterBy as _filterBy, + uniq as _uniq, + uniqBy as _uniqBy, + union as _union, + intersect as _intersect, + collect as _collect, +} from './lib/computed/reduce_computed_macros'; -// Fires once per app load, when anything imports this module: the whole -// computed-property macro surface is deprecated together. -deprecateUntil( - 'Importing from `@ember/object/computed` is deprecated. Computed property macros are part of the classic object model; replace them with `@tracked` properties and native getters (with `@cached` where memoization is needed).', - DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES -); +// Call-time deprecation wrappers, one per macro, rather than a single +// module-eval deprecation: consumer bundlers treat this module as +// side-effect-free (see `sideEffects` in package.json) and are allowed to +// drop top-level statements when only re-exports are used. ember-source's +// own internals import the underlying lib modules directly and stay silent. +function deprecatedMacro(macro: F): F { + return function (this: unknown, ...args: unknown[]) { + deprecateUntil( + 'Computed property macros are deprecated. Replace them with `@tracked` properties and native getters (with `@cached` where memoization is needed).', + DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES + ); + return macro.apply(this, args); + } as F; +} export { ComputedProperty as default } from '@ember/-internals/metal/lib/computed'; -export { default as expandProperties } from '@ember/-internals/metal/lib/expand_properties'; -export { default as alias } from '@ember/-internals/metal/lib/alias'; -export { - empty, - notEmpty, - none, - not, - bool, - match, - equal, - gt, - gte, - lt, - lte, - oneWay, - oneWay as reads, - readOnly, - deprecatingAlias, - and, - or, -} from './lib/computed/computed_macros'; +export const expandProperties = deprecatedMacro(metalExpandProperties); +export const alias = deprecatedMacro(metalAlias); -export { - sum, - min, - max, - map, - sort, - setDiff, - mapBy, - filter, - filterBy, - uniq, - uniqBy, - union, - intersect, - collect, -} from './lib/computed/reduce_computed_macros'; +export const empty = deprecatedMacro(_empty); +export const notEmpty = deprecatedMacro(_notEmpty); +export const none = deprecatedMacro(_none); +export const not = deprecatedMacro(_not); +export const bool = deprecatedMacro(_bool); +export const match = deprecatedMacro(_match); +export const equal = deprecatedMacro(_equal); +export const gt = deprecatedMacro(_gt); +export const gte = deprecatedMacro(_gte); +export const lt = deprecatedMacro(_lt); +export const lte = deprecatedMacro(_lte); +export const oneWay = deprecatedMacro(_oneWay); +export { oneWay as reads }; +export const readOnly = deprecatedMacro(_readOnly); +export const deprecatingAlias = deprecatedMacro(_deprecatingAlias); +export const and = deprecatedMacro(_and); +export const or = deprecatedMacro(_or); + +export const sum = deprecatedMacro(_sum); +export const min = deprecatedMacro(_min); +export const max = deprecatedMacro(_max); +export const map = deprecatedMacro(_map); +export const sort = deprecatedMacro(_sort); +export const setDiff = deprecatedMacro(_setDiff); +export const mapBy = deprecatedMacro(_mapBy); +export const filter = deprecatedMacro(_filter); +export const filterBy = deprecatedMacro(_filterBy); +export const uniq = deprecatedMacro(_uniq); +export const uniqBy = deprecatedMacro(_uniqBy); +export const union = deprecatedMacro(_union); +export const intersect = deprecatedMacro(_intersect); +export const collect = deprecatedMacro(_collect); diff --git a/packages/@ember/object/tests/computed-observers-deprecations-test.js b/packages/@ember/object/tests/computed-observers-deprecations-test.js index e66c1234f11..19f3b42ba4e 100644 --- a/packages/@ember/object/tests/computed-observers-deprecations-test.js +++ b/packages/@ember/object/tests/computed-observers-deprecations-test.js @@ -1,5 +1,7 @@ import EmberObject, { computed, observer } from '@ember/object'; +import { readOnly } from '@ember/object/computed'; import { addObserver, removeObserver } from '@ember/object/observers'; +import { readOnly as metalReadOnly } from '@ember/object/lib/computed/computed_macros'; import metalComputed from '@ember/-internals/metal/lib/computed'; import { addObserver as metalAddObserver, @@ -10,11 +12,6 @@ import { moduleForDevelopment, AbstractTestCase, runLoopSettled } from 'internal const IDS = ['deprecate-computed-properties', 'deprecate-observers']; -// The module-eval deprecation on the `@ember/object/computed` macros barrel -// cannot be asserted here: the module already evaluated (silently, since -// these ids are available-stage and off by default) when the suite loaded. -// It is exercised by any consumer that imports the barrel with the id -// enabled at boot. moduleForDevelopment( 'computed property and observer deprecations', class extends AbstractTestCase { @@ -29,6 +26,7 @@ moduleForDevelopment( computed('first', function () { return this.first; }); + readOnly('first'); observer('first', function () {}); addObserver(obj, 'first', null, handler, true); removeObserver(obj, 'first', null, handler, true); @@ -55,6 +53,29 @@ moduleForDevelopment( obj.destroy(); } + ['@test computed macros fire when enabled and still work'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let cp; + expectDeprecation(() => { + cp = readOnly('first'); + }, /Computed property macros are deprecated/); + + let Klass = EmberObject.extend({ first: 'a', firstAlias: cp }); + let obj = Klass.create(); + assert.strictEqual(obj.get('firstAlias'), 'a', 'the macro works'); + obj.destroy(); + } + + ['@test the deep macro modules never fire'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + expectNoDeprecation(() => { + metalReadOnly('first'); + }); + assert.ok(true, 'no deprecations fired'); + } + ['@test observer() fires when enabled']() { setDeprecationStagesConfig({ enable: IDS }); From e4965747ba5b63cb4daa0bb12842a648dbb0cc1b Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 16:26:23 -0700 Subject: [PATCH 20/24] Correct the RFC appendix for the call-time macro wrappers Co-Authored-By: Claude Opus 4.8 (1M context) --- internal-docs/rfcs/deprecation-early-enablement-and-shaking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index 607ac1d8703..f93fbb8c1d9 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -329,7 +329,7 @@ available-stage (`since: { available: '7.3.0' }`, no `enabled`), `until: | `deprecate-ember-object-extend` | the public static `.extend()` | native classes (ember-native-class-codemod) | | `deprecate-ember-object-reopen` | the public statics `.reopen()` / `.reopenClass()` | subclassing / module refactor | | `deprecate-ember-mixins` | `Mixin.create` | native class composition | -| `deprecate-computed-properties` | the public `computed()` and module eval of `@ember/object/computed` | `@tracked` + native getters, `@cached` | +| `deprecate-computed-properties` | the public `computed()` and every `@ember/object/computed` macro (call-time wrappers — a module-eval deprecation would be dropped by `sideEffects`-aware bundlers) | `@tracked` + native getters, `@cached` | | `deprecate-observers` | `observer()`, public `addObserver`/`removeObserver` | derived state / explicit events | | `deprecate-ember-array` | `A()` | native arrays / tracked-built-ins | | `deprecate-object-proxy` | `ObjectProxy` init (once per class) | direct access / native getters | From 191b5a1a6b6bf3e63dd4524798bb8bba38844c0e Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 17:29:51 -0700 Subject: [PATCH 21/24] Address review findings in the wave-1 deprecations The proxy dedupe sets only record classes while their deprecation is enabled, so enabling later still warns; expandProperties gets an accurate message; createMixin notes its intentional divergence from the this-based static. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/@ember/array/proxy.ts | 5 ++++- packages/@ember/object/computed.ts | 15 +++++++++------ packages/@ember/object/mixin.ts | 3 +++ packages/@ember/object/proxy.ts | 5 ++++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/@ember/array/proxy.ts b/packages/@ember/array/proxy.ts index ee7babc86d9..72c1ed65cd4 100644 --- a/packages/@ember/array/proxy.ts +++ b/packages/@ember/array/proxy.ts @@ -207,7 +207,10 @@ class ArrayProxy extends EmberObject implements PropertyDidChange { init(props: object | undefined) { super.init(props); - if (!deprecatedClasses.has(this.constructor)) { + // The isEnabled gate keeps the dedupe set empty while the deprecation is + // disabled, so enabling it later (e.g. per test module) still warns for + // classes instantiated before that point. + if (DEPRECATIONS.DEPRECATE_ARRAY_PROXY.isEnabled && !deprecatedClasses.has(this.constructor)) { deprecatedClasses.add(this.constructor); deprecateUntil( 'ArrayProxy is deprecated. Use a native array, or a tracked collection from tracked-built-ins, and expose derived state with native getters.', diff --git a/packages/@ember/object/computed.ts b/packages/@ember/object/computed.ts index d820ba487cc..862da118a43 100644 --- a/packages/@ember/object/computed.ts +++ b/packages/@ember/object/computed.ts @@ -42,19 +42,22 @@ import { // side-effect-free (see `sideEffects` in package.json) and are allowed to // drop top-level statements when only re-exports are used. ember-source's // own internals import the underlying lib modules directly and stay silent. -function deprecatedMacro(macro: F): F { +function deprecatedMacro( + macro: F, + message = 'Computed property macros are deprecated. Replace them with `@tracked` properties and native getters (with `@cached` where memoization is needed).' +): F { return function (this: unknown, ...args: unknown[]) { - deprecateUntil( - 'Computed property macros are deprecated. Replace them with `@tracked` properties and native getters (with `@cached` where memoization is needed).', - DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES - ); + deprecateUntil(message, DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES); return macro.apply(this, args); } as F; } export { ComputedProperty as default } from '@ember/-internals/metal/lib/computed'; -export const expandProperties = deprecatedMacro(metalExpandProperties); +export const expandProperties = deprecatedMacro( + metalExpandProperties, + '`expandProperties` is deprecated along with the computed property system it supports.' +); export const alias = deprecatedMacro(metalAlias); export const empty = deprecatedMacro(_empty); diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index 2dd540bc0de..ce3bd601ea0 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -696,6 +696,9 @@ export default class Mixin { Non-deprecating equivalent of `Mixin.create` for ember-source's own framework mixins. External code must use `Mixin.create`. + Constructs `Mixin` directly rather than the static's `this`-based `new M()` + — ember-source never subclasses Mixin internally. + @internal */ export function createMixin(...args: any[]): Mixin { diff --git a/packages/@ember/object/proxy.ts b/packages/@ember/object/proxy.ts index b6aab2cd8ff..a74889c16b8 100644 --- a/packages/@ember/object/proxy.ts +++ b/packages/@ember/object/proxy.ts @@ -129,7 +129,10 @@ class ObjectProxy extends FrameworkObject { init(properties: object | undefined) { super.init(properties); - if (!deprecatedClasses.has(this.constructor)) { + // The isEnabled gate keeps the dedupe set empty while the deprecation is + // disabled, so enabling it later (e.g. per test module) still warns for + // classes instantiated before that point. + if (DEPRECATIONS.DEPRECATE_OBJECT_PROXY.isEnabled && !deprecatedClasses.has(this.constructor)) { deprecatedClasses.add(this.constructor); deprecateUntil( 'ObjectProxy is deprecated. Access the underlying object directly, or expose derived state with native getters.', From 7221f6b3a9749f81a8d47b2c72dd8dfd519f9bb0 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 17:30:18 -0700 Subject: [PATCH 22/24] Shield excepted ids from the removal simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OVERRIDE_DEPRECATION_VERSION=15 made every wave-1 deprecation (until 8.0.0) throw the removal error across the suite and the smoke apps — ember's tests and the app-testing ecosystem still use the classic APIs. except now also excludes an id from the version-based removal computation (never from a false shaking flag: that code is actually gone), and the Deprecations-as-errors CI rows plus the deprecations-removed smoke job carry the classic except list, threaded into the app templates via EXCEPT_DEPRECATIONS. setDeprecationStagesConfig(null) now restores the boot (EmberENV) config instead of clearing it — test teardowns were wiping the harness variant's config for the rest of the suite. {} is an explicitly empty config. Warn-expecting deprecation tests skip under removal simulation via testUnless, the standard pattern for removed deprecations. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-jobs.yml | 9 +++++ ...eprecation-early-enablement-and-shaking.md | 8 +++-- .../@ember/-internals/deprecations/index.ts | 15 ++++++-- .../deprecations/tests/index-test.js | 34 +++++++++++++++++-- .../@ember/array/tests/a-deprecation-test.js | 10 ++++-- .../@ember/debug/lib/deprecation-stages.ts | 21 +++++++++--- .../debug/tests/deprecation-stages-test.js | 4 +-- .../classic-object-model-deprecations-test.js | 16 ++++++--- .../computed-observers-deprecations-test.js | 26 +++++++++++--- .../object/tests/proxy-deprecations-test.js | 12 +++++-- .../app-template/config/environment.js | 9 +++-- .../v2-app-template/config/environment.js | 9 +++-- 12 files changed, 140 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index a026227480e..40d823ad816 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -113,10 +113,15 @@ jobs: - name: "Deprecation compliance declared" DEPRECATION_COMPLIANCE: "7.2.0" RAISE_ON_DEPRECATION: "false" + # The classic ids are excepted here too: except also shields an id + # from the OVERRIDE_DEPRECATION_VERSION removal simulation, and the + # suite exercises the classic APIs pervasively. - name: "Deprecations as errors" OVERRIDE_DEPRECATION_VERSION: "15.0.0" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" - name: "Deprecations as errors, with optional features" OVERRIDE_DEPRECATION_VERSION: "15.0.0" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" ENABLE_OPTIONAL_FEATURES: "true" - name: "Production build" BUILD: "production" @@ -215,6 +220,10 @@ jobs: - name: test env: OVERRIDE_DEPRECATION_VERSION: "15.0.0" + # The classic ids are shielded from the removal simulation: the + # app-testing ecosystem (@ember/test-helpers, ember-qunit) still + # uses the classic APIs. + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" MATRIX_COMMAND: ${{ matrix.command }} working-directory: smoke-tests/scenarios run: | diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index f93fbb8c1d9..57f559914d6 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -131,9 +131,11 @@ Semantics: through `@ember/debug` — including addon deprecations with their own `for` — not only Ember's own. - Precedence: `except` > `assert` > `compliance`, and `except` also excludes - an id from `enable`. `except` means "pretend this id is not configured" — - the lever that lets `enable: true` coexist with a handful of - known-too-noisy ids. + an id from `enable` and from the version-based removal simulation + (`_OVERRIDE_DEPRECATION_VERSION`). `except` means "pretend this id is not + configured" — the lever that lets blanket modes (`enable: true`, simulated + future versions) coexist with a handful of known-too-noisy ids. It never + overrides a shaken build: code a flag removed is actually gone. - A compliance declaration for a package version newer than the installed version is invalid (asserts), mirroring RFC 0649's rule against optimistic declarations. diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index c5137b55c54..26fe4d1b3b9 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -1,5 +1,8 @@ import type { DeprecationOptions } from '@ember/debug/lib/deprecate'; -import { isDeprecationEnabledByConfig } from '@ember/debug/lib/deprecation-stages'; +import { + isDeprecationEnabledByConfig, + isDeprecationExceptedByConfig, +} from '@ember/debug/lib/deprecation-stages'; import { ENV } from '@ember/-internals/environment/lib/env'; import { VERSION } from '@ember/version'; import { deprecate, assert } from '@ember/debug'; @@ -41,6 +44,12 @@ interface DeprecationObject { // constant: in a build where the flag is false the guarded implementation is // gone, so the deprecation reports itself as removed and unguarded reaches // throw via deprecateUntil. +// +// `except` shields an id from the version-based removal computation (which +// includes the _OVERRIDE_DEPRECATION_VERSION simulation) — without it, the +// "Deprecations as errors" CI variant would throw for every API whose +// deprecation is intentionally excluded from a run. It does not shield a +// false flag: in a shaken build the implementation is actually gone. export function deprecation(options: DeprecationOptions, flag?: boolean): DeprecationObject { return { options, @@ -48,10 +57,10 @@ export function deprecation(options: DeprecationOptions, flag?: boolean): Deprec return !isEnabled(options); }, get isEnabled() { - return isEnabled(options) || isRemoved(options) || flag === false; + return isEnabled(options) || this.isRemoved; }, get isRemoved() { - return isRemoved(options) || flag === false; + return (isRemoved(options) && !isDeprecationExceptedByConfig(options.id)) || flag === false; }, }; } diff --git a/packages/@ember/-internals/deprecations/tests/index-test.js b/packages/@ember/-internals/deprecations/tests/index-test.js index a5f2b155f9e..4679188d843 100644 --- a/packages/@ember/-internals/deprecations/tests/index-test.js +++ b/packages/@ember/-internals/deprecations/tests/index-test.js @@ -55,6 +55,30 @@ moduleFor( } } + ['@test except shields an id from version-based removal, but not from a false flag'](assert) { + let options = { + id: 'test-past-until', + until: '3.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-past-until', + since: { available: '1.0.0' }, + }; + + assert.true(deprecation(options).isRemoved, 'past-until deprecation reports removed'); + + setDeprecationStagesConfig({ except: ['test-past-until'] }); + + assert.false(deprecation(options).isRemoved, 'excepted id is not removed'); + assert.false(deprecation(options).isEnabled, 'and not enabled via removal'); + assert.true( + deprecation(options, false).isRemoved, + 'a false flag still reports removed: the code is actually gone' + ); + + deprecateUntil('Reaching an excepted past-until deprecation', deprecation(options)); + assert.ok(true, 'deprecateUntil does not throw for the excepted id'); + } + ['@test a deprecation whose flag is false reports itself as removed'](assert) { let options = { id: 'test-flagged-off', @@ -76,6 +100,9 @@ moduleFor( } ['@test available-stage deprecations reflect stage config changes'](assert) { + // explicitly empty: the harness variant may run with a boot config + setDeprecationStagesConfig({}); + let AVAILABLE_DEPRECATION = deprecation({ id: 'test-available-stage', until: '30.0.0', @@ -84,8 +111,8 @@ moduleFor( since: { available: '1.0.0' }, }); - assert.true(AVAILABLE_DEPRECATION.test, 'suppressed with no config'); - assert.false(AVAILABLE_DEPRECATION.isEnabled, 'not enabled with no config'); + assert.true(AVAILABLE_DEPRECATION.test, 'suppressed with empty config'); + assert.false(AVAILABLE_DEPRECATION.isEnabled, 'not enabled with empty config'); setDeprecationStagesConfig({ enable: ['test-available-stage'] }); @@ -98,6 +125,9 @@ moduleFor( } ['@test deprecateUntil fires an available-stage deprecation enabled by config'](assert) { + // explicitly empty: the harness variant may run with a boot config + setDeprecationStagesConfig({}); + let AVAILABLE_DEPRECATION = deprecation({ id: 'test-available-fires', until: '30.0.0', diff --git a/packages/@ember/array/tests/a-deprecation-test.js b/packages/@ember/array/tests/a-deprecation-test.js index be86bfe358d..6d902316ae2 100644 --- a/packages/@ember/array/tests/a-deprecation-test.js +++ b/packages/@ember/array/tests/a-deprecation-test.js @@ -1,6 +1,12 @@ import { A, internalA } from '@ember/array'; import { setDeprecationStagesConfig } from '@ember/debug'; -import { moduleForDevelopment, AbstractTestCase } from 'internal-test-helpers'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { moduleForDevelopment, testUnless, AbstractTestCase } from 'internal-test-helpers'; + +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); moduleForDevelopment( 'Ember Array deprecation', @@ -16,7 +22,7 @@ moduleForDevelopment( assert.ok(true, 'no deprecations fired'); } - ['@test A() fires when enabled and still works'](assert) { + [`${testUnless(REMOVAL_SIMULATED)} A() fires when enabled and still works`](assert) { setDeprecationStagesConfig({ enable: ['deprecate-ember-array'] }); let arr; diff --git a/packages/@ember/debug/lib/deprecation-stages.ts b/packages/@ember/debug/lib/deprecation-stages.ts index 9905cf2e31e..be4920dc5db 100644 --- a/packages/@ember/debug/lib/deprecation-stages.ts +++ b/packages/@ember/debug/lib/deprecation-stages.ts @@ -40,13 +40,14 @@ export interface DeprecationStagesConfig { /** Ids this configuration should treat as unconfigured: exempted from - `compliance`/`assert` throwing and from `enable` (including - `enable: true`). + `compliance`/`assert` throwing, from `enable` (including `enable: true`), + and from the removal simulation of `_OVERRIDE_DEPRECATION_VERSION`. */ except?: string[]; } let isDeprecationEnabledByConfig: (id: string) => boolean = () => false; +let isDeprecationExceptedByConfig: (id: string) => boolean = () => false; let shouldThrowForDeprecation: (options: DeprecationOptions) => boolean = () => false; let setDeprecationStagesConfig: (config: DeprecationStagesConfig | null) => void = () => {}; @@ -156,11 +157,14 @@ if (DEBUG) { return normalized; }; - let current = normalize(ENV.DEPRECATION_STAGES as DeprecationStagesConfig | null); + let bootConfig = ENV.DEPRECATION_STAGES as DeprecationStagesConfig | null; + let current = normalize(bootConfig); isDeprecationEnabledByConfig = (id) => (current.enableAll || current.enabledIds.has(id)) && !current.exceptIds.has(id); + isDeprecationExceptedByConfig = (id) => current.exceptIds.has(id); + shouldThrowForDeprecation = (options) => { if (current.exceptIds.has(options.id)) { return false; @@ -175,9 +179,16 @@ if (DEBUG) { return false; }; + // null restores the boot (EmberENV) configuration — the correct teardown + // for tests that swapped it — while `{}` is an explicitly empty config. setDeprecationStagesConfig = (config) => { - current = normalize(config); + current = normalize(config ?? bootConfig); }; } -export { isDeprecationEnabledByConfig, shouldThrowForDeprecation, setDeprecationStagesConfig }; +export { + isDeprecationEnabledByConfig, + isDeprecationExceptedByConfig, + shouldThrowForDeprecation, + setDeprecationStagesConfig, +}; diff --git a/packages/@ember/debug/tests/deprecation-stages-test.js b/packages/@ember/debug/tests/deprecation-stages-test.js index 4cf7b80f76b..986c74b69f7 100644 --- a/packages/@ember/debug/tests/deprecation-stages-test.js +++ b/packages/@ember/debug/tests/deprecation-stages-test.js @@ -40,8 +40,8 @@ moduleForDevelopment( console.warn = originalConsoleWarn; // eslint-disable-line no-console } - ['@test no config: nothing is enabled or thrown'](assert) { - setDeprecationStagesConfig(null); + ['@test empty config: nothing is enabled or thrown'](assert) { + setDeprecationStagesConfig({}); assert.false(isDeprecationEnabledByConfig('some-id'), 'no id is enabled'); deprecate('enabled-stage deprecation warns without throwing', false, enabledOptions('e1')); diff --git a/packages/@ember/object/tests/classic-object-model-deprecations-test.js b/packages/@ember/object/tests/classic-object-model-deprecations-test.js index 57c3751b723..6204643aa94 100644 --- a/packages/@ember/object/tests/classic-object-model-deprecations-test.js +++ b/packages/@ember/object/tests/classic-object-model-deprecations-test.js @@ -4,11 +4,13 @@ import Mixin, { createMixin } from '@ember/object/mixin'; import Application from '@ember/application'; import EmberRouter from '@ember/routing/router'; import { setDeprecationStagesConfig } from '@ember/debug'; +import { emberVersionGte } from '@ember/-internals/deprecations'; import { moduleForDevelopment, AbstractTestCase, ModuleBasedTestResolver, runTask, + testUnless, } from 'internal-test-helpers'; const CLASSIC_IDS = [ @@ -17,6 +19,12 @@ const CLASSIC_IDS = [ 'deprecate-ember-mixins', ]; +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped — the standard pattern for tests +// of removed deprecations. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + moduleForDevelopment( 'classic object model deprecations', class extends AbstractTestCase { @@ -34,7 +42,7 @@ moduleForDevelopment( assert.ok(true, 'no deprecations fired'); } - ['@test extend fires when enabled']() { + [`${testUnless(REMOVAL_SIMULATED)} extend fires when enabled`]() { setDeprecationStagesConfig({ enable: CLASSIC_IDS }); expectDeprecation(() => { @@ -42,7 +50,7 @@ moduleForDevelopment( }, /The classic class definition API `\.extend\(\)` is deprecated/); } - ['@test extend fires for subclasses created with extend']() { + [`${testUnless(REMOVAL_SIMULATED)} extend fires for subclasses created with extend`]() { setDeprecationStagesConfig({ enable: CLASSIC_IDS }); let Klass; @@ -55,7 +63,7 @@ moduleForDevelopment( }, /`\.extend\(\)` is deprecated/); } - ['@test reopen and reopenClass fire when enabled']() { + [`${testUnless(REMOVAL_SIMULATED)} reopen and reopenClass fire when enabled`]() { setDeprecationStagesConfig({ enable: CLASSIC_IDS }); class Klass extends EmberObject {} @@ -69,7 +77,7 @@ moduleForDevelopment( }, /The classic class API `\.reopenClass\(\)` is deprecated/); } - ['@test Mixin.create fires when enabled']() { + [`${testUnless(REMOVAL_SIMULATED)} Mixin.create fires when enabled`]() { setDeprecationStagesConfig({ enable: CLASSIC_IDS }); expectDeprecation(() => { diff --git a/packages/@ember/object/tests/computed-observers-deprecations-test.js b/packages/@ember/object/tests/computed-observers-deprecations-test.js index 19f3b42ba4e..0bfb088f990 100644 --- a/packages/@ember/object/tests/computed-observers-deprecations-test.js +++ b/packages/@ember/object/tests/computed-observers-deprecations-test.js @@ -8,10 +8,21 @@ import { removeObserver as metalRemoveObserver, } from '@ember/-internals/metal/lib/observer'; import { setDeprecationStagesConfig } from '@ember/debug'; -import { moduleForDevelopment, AbstractTestCase, runLoopSettled } from 'internal-test-helpers'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { + moduleForDevelopment, + testUnless, + AbstractTestCase, + runLoopSettled, +} from 'internal-test-helpers'; const IDS = ['deprecate-computed-properties', 'deprecate-observers']; +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + moduleForDevelopment( 'computed property and observer deprecations', class extends AbstractTestCase { @@ -35,7 +46,7 @@ moduleForDevelopment( assert.ok(true, 'no deprecations fired'); } - ['@test computed() fires when enabled and still works'](assert) { + [`${testUnless(REMOVAL_SIMULATED)} computed() fires when enabled and still works`](assert) { setDeprecationStagesConfig({ enable: IDS }); let cp; @@ -53,7 +64,7 @@ moduleForDevelopment( obj.destroy(); } - ['@test computed macros fire when enabled and still work'](assert) { + [`${testUnless(REMOVAL_SIMULATED)} computed macros fire per call and still work`](assert) { setDeprecationStagesConfig({ enable: IDS }); let cp; @@ -61,6 +72,11 @@ moduleForDevelopment( cp = readOnly('first'); }, /Computed property macros are deprecated/); + // no dedupe: a second call fires again + expectDeprecation(() => { + readOnly('second'); + }, /Computed property macros are deprecated/); + let Klass = EmberObject.extend({ first: 'a', firstAlias: cp }); let obj = Klass.create(); assert.strictEqual(obj.get('firstAlias'), 'a', 'the macro works'); @@ -76,7 +92,7 @@ moduleForDevelopment( assert.ok(true, 'no deprecations fired'); } - ['@test observer() fires when enabled']() { + [`${testUnless(REMOVAL_SIMULATED)} observer() fires when enabled`]() { setDeprecationStagesConfig({ enable: IDS }); expectDeprecation(() => { @@ -84,7 +100,7 @@ moduleForDevelopment( }, /Observers are deprecated/); } - ['@test addObserver and removeObserver fire when enabled'](assert) { + [`${testUnless(REMOVAL_SIMULATED)} addObserver and removeObserver fire when enabled`](assert) { setDeprecationStagesConfig({ enable: IDS }); let obj = EmberObject.create({ first: 'a' }); diff --git a/packages/@ember/object/tests/proxy-deprecations-test.js b/packages/@ember/object/tests/proxy-deprecations-test.js index 4c85e002e8b..7ca0cbfb840 100644 --- a/packages/@ember/object/tests/proxy-deprecations-test.js +++ b/packages/@ember/object/tests/proxy-deprecations-test.js @@ -1,10 +1,16 @@ import ObjectProxy from '@ember/object/proxy'; import ArrayProxy from '@ember/array/proxy'; import { setDeprecationStagesConfig } from '@ember/debug'; -import { moduleForDevelopment, AbstractTestCase } from 'internal-test-helpers'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { moduleForDevelopment, testUnless, AbstractTestCase } from 'internal-test-helpers'; const IDS = ['deprecate-object-proxy', 'deprecate-array-proxy']; +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + moduleForDevelopment( 'ObjectProxy and ArrayProxy deprecations', class extends AbstractTestCase { @@ -20,7 +26,7 @@ moduleForDevelopment( assert.ok(true, 'no deprecations fired'); } - ['@test ObjectProxy fires once per class when enabled'](assert) { + [`${testUnless(REMOVAL_SIMULATED)} ObjectProxy fires once per class when enabled`](assert) { setDeprecationStagesConfig({ enable: IDS }); class ProxyA extends ObjectProxy {} @@ -45,7 +51,7 @@ moduleForDevelopment( [first, second, third].forEach((proxy) => proxy.destroy()); } - ['@test ArrayProxy fires once per class when enabled'](assert) { + [`${testUnless(REMOVAL_SIMULATED)} ArrayProxy fires once per class when enabled`](assert) { setDeprecationStagesConfig({ enable: IDS }); class ProxyA extends ArrayProxy {} diff --git a/smoke-tests/app-template/config/environment.js b/smoke-tests/app-template/config/environment.js index f5e5c491ff3..78be3f7fff4 100644 --- a/smoke-tests/app-template/config/environment.js +++ b/smoke-tests/app-template/config/environment.js @@ -7,10 +7,15 @@ module.exports = function (environment) { rootURL: '/', locationType: 'history', EmberENV: { - /* The following enables the infrastructure allow us to test as if deprecations + /* The following enables the infrastructure allow us to test as if deprecations have been turned into errors at a specific version. */ - _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + // Deprecation ids to shield from the removal simulation (and blanket + // enablement) — comma-separated, mirroring the main test harness. + DEPRECATION_STAGES: process.env.EXCEPT_DEPRECATIONS + ? { except: process.env.EXCEPT_DEPRECATIONS.split(',') } + : undefined, EXTEND_PROTOTYPES: false, FEATURES: { // Here you can enable experimental features on an ember canary build diff --git a/smoke-tests/v2-app-template/config/environment.js b/smoke-tests/v2-app-template/config/environment.js index 9c2e006a05b..54408a4c2f9 100644 --- a/smoke-tests/v2-app-template/config/environment.js +++ b/smoke-tests/v2-app-template/config/environment.js @@ -7,10 +7,15 @@ module.exports = function (environment) { rootURL: '/', locationType: 'history', EmberENV: { - /* The following enables the infrastructure allow us to test as if deprecations + /* The following enables the infrastructure allow us to test as if deprecations have been turned into errors at a specific version. */ - _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + // Deprecation ids to shield from the removal simulation (and blanket + // enablement) — comma-separated, mirroring the main test harness. + DEPRECATION_STAGES: process.env.EXCEPT_DEPRECATIONS + ? { except: process.env.EXCEPT_DEPRECATIONS.split(',') } + : undefined, EXTEND_PROTOTYPES: false, FEATURES: { // Here you can enable experimental features on an ember canary build From b3e8e64ba094ce042e94797f31c93c1a559dbabf Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 20:11:34 -0700 Subject: [PATCH 23/24] Keep stage-config registry tests out of production runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stage configuration functions are no-op stubs in production builds, so the tests that exercise them move to a development-only module. The isRemoved compliance test starts from an explicitly empty config — the compliance CI variant's boot config now survives teardown (null restores it) and would otherwise make its enabled-stage deprecation throw. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deprecations/tests/index-test.js | 166 ++++++++++-------- 1 file changed, 91 insertions(+), 75 deletions(-) diff --git a/packages/@ember/-internals/deprecations/tests/index-test.js b/packages/@ember/-internals/deprecations/tests/index-test.js index 4679188d843..4860403515f 100644 --- a/packages/@ember/-internals/deprecations/tests/index-test.js +++ b/packages/@ember/-internals/deprecations/tests/index-test.js @@ -1,4 +1,4 @@ -import { AbstractTestCase, moduleFor } from 'internal-test-helpers'; +import { AbstractTestCase, moduleFor, moduleForDevelopment } from 'internal-test-helpers'; import { DEPRECATIONS, deprecation, deprecateUntil, isRemoved, emberVersionGte } from '../index'; import { ENV } from '@ember/-internals/environment'; import { setDeprecationStagesConfig } from '@ember/debug'; @@ -55,30 +55,6 @@ moduleFor( } } - ['@test except shields an id from version-based removal, but not from a false flag'](assert) { - let options = { - id: 'test-past-until', - until: '3.0.0', - for: 'ember-source', - url: 'http://example.com/deprecations/test-past-until', - since: { available: '1.0.0' }, - }; - - assert.true(deprecation(options).isRemoved, 'past-until deprecation reports removed'); - - setDeprecationStagesConfig({ except: ['test-past-until'] }); - - assert.false(deprecation(options).isRemoved, 'excepted id is not removed'); - assert.false(deprecation(options).isEnabled, 'and not enabled via removal'); - assert.true( - deprecation(options, false).isRemoved, - 'a false flag still reports removed: the code is actually gone' - ); - - deprecateUntil('Reaching an excepted past-until deprecation', deprecation(options)); - assert.ok(true, 'deprecateUntil does not throw for the excepted id'); - } - ['@test a deprecation whose flag is false reports itself as removed'](assert) { let options = { id: 'test-flagged-off', @@ -99,56 +75,6 @@ moduleFor( ); } - ['@test available-stage deprecations reflect stage config changes'](assert) { - // explicitly empty: the harness variant may run with a boot config - setDeprecationStagesConfig({}); - - let AVAILABLE_DEPRECATION = deprecation({ - id: 'test-available-stage', - until: '30.0.0', - for: 'ember-source', - url: 'http://example.com/deprecations/test-available-stage', - since: { available: '1.0.0' }, - }); - - assert.true(AVAILABLE_DEPRECATION.test, 'suppressed with empty config'); - assert.false(AVAILABLE_DEPRECATION.isEnabled, 'not enabled with empty config'); - - setDeprecationStagesConfig({ enable: ['test-available-stage'] }); - - assert.false(AVAILABLE_DEPRECATION.test, 'fires once enabled by config'); - assert.true(AVAILABLE_DEPRECATION.isEnabled, 'enabled by config'); - - setDeprecationStagesConfig({ enable: ['some-other-id'] }); - - assert.true(AVAILABLE_DEPRECATION.test, 'suppressed again when config changes'); - } - - ['@test deprecateUntil fires an available-stage deprecation enabled by config'](assert) { - // explicitly empty: the harness variant may run with a boot config - setDeprecationStagesConfig({}); - - let AVAILABLE_DEPRECATION = deprecation({ - id: 'test-available-fires', - until: '30.0.0', - for: 'ember-source', - url: 'http://example.com/deprecations/test-available-fires', - since: { available: '1.0.0' }, - }); - - expectNoDeprecation(() => { - deprecateUntil('This deprecation is suppressed', AVAILABLE_DEPRECATION); - }); - - setDeprecationStagesConfig({ enable: ['test-available-fires'] }); - - expectDeprecation(() => { - deprecateUntil('This deprecation fires', AVAILABLE_DEPRECATION); - }, /This deprecation fires/); - - assert.ok(true, 'ran without throwing'); - } - ['@test deprecateUntil throws when deprecation has been removed'](assert) { assert.expect(1); @@ -176,6 +102,10 @@ moduleFor( ['@test deprecateUntil does not throw when isRemoved is false on deprecation'](assert) { assert.expect(1); + // explicitly empty: the compliance CI variant's boot config would + // otherwise make this enabled-stage deprecation throw + setDeprecationStagesConfig({}); + let MY_DEPRECATION = { options: { id: 'test', @@ -267,3 +197,89 @@ moduleFor( } } ); + +// Stage configuration only exists in debug builds (the config functions are +// no-op stubs in production), so these run as development-only. +moduleForDevelopment( + '@ember/-internals/deprecations: stage configuration', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test available-stage deprecations reflect stage config changes'](assert) { + // explicitly empty: the harness variant may run with a boot config + setDeprecationStagesConfig({}); + + let AVAILABLE_DEPRECATION = deprecation({ + id: 'test-available-stage', + until: '30.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-available-stage', + since: { available: '1.0.0' }, + }); + + assert.true(AVAILABLE_DEPRECATION.test, 'suppressed with empty config'); + assert.false(AVAILABLE_DEPRECATION.isEnabled, 'not enabled with empty config'); + + setDeprecationStagesConfig({ enable: ['test-available-stage'] }); + + assert.false(AVAILABLE_DEPRECATION.test, 'fires once enabled by config'); + assert.true(AVAILABLE_DEPRECATION.isEnabled, 'enabled by config'); + + setDeprecationStagesConfig({ enable: ['some-other-id'] }); + + assert.true(AVAILABLE_DEPRECATION.test, 'suppressed again when config changes'); + } + + ['@test deprecateUntil fires an available-stage deprecation enabled by config'](assert) { + // explicitly empty: the harness variant may run with a boot config + setDeprecationStagesConfig({}); + + let AVAILABLE_DEPRECATION = deprecation({ + id: 'test-available-fires', + until: '30.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-available-fires', + since: { available: '1.0.0' }, + }); + + expectNoDeprecation(() => { + deprecateUntil('This deprecation is suppressed', AVAILABLE_DEPRECATION); + }); + + setDeprecationStagesConfig({ enable: ['test-available-fires'] }); + + expectDeprecation(() => { + deprecateUntil('This deprecation fires', AVAILABLE_DEPRECATION); + }, /This deprecation fires/); + + assert.ok(true, 'ran without throwing'); + } + + ['@test except shields an id from version-based removal, but not from a false flag'](assert) { + let options = { + id: 'test-past-until', + until: '3.0.0', + for: 'ember-source', + url: 'http://example.com/deprecations/test-past-until', + since: { available: '1.0.0' }, + }; + + setDeprecationStagesConfig({}); + assert.true(deprecation(options).isRemoved, 'past-until deprecation reports removed'); + + setDeprecationStagesConfig({ except: ['test-past-until'] }); + + assert.false(deprecation(options).isRemoved, 'excepted id is not removed'); + assert.false(deprecation(options).isEnabled, 'and not enabled via removal'); + assert.true( + deprecation(options, false).isRemoved, + 'a false flag still reports removed: the code is actually gone' + ); + + deprecateUntil('Reaching an excepted past-until deprecation', deprecation(options)); + assert.ok(true, 'deprecateUntil does not throw for the excepted id'); + } + } +); From 7173c1cf8c558a523e9dd2ce55087bf67cb42a24 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Fri, 17 Jul 2026 12:25:12 -0700 Subject: [PATCH 24/24] Keep reopen call sites tree-shakable via internal statics Module-scope calls to the imported internalReopen/internalReopenClass helpers made six modules permanently side-effectful (caught by the tree-shakability snapshot): rollup cannot scope an imported helper call, but it can scope a static method call to its class. The non-deprecating internal entry points for reopen/reopenClass are now reopenInternal/reopenClassInternal statics on CoreObject; the module functions remain as their implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../@ember/-internals/deprecations/index.ts | 13 +++++---- .../-internals/glimmer/lib/component.ts | 4 +-- packages/@ember/array/proxy.ts | 3 +-- packages/@ember/engine/index.ts | 4 +-- packages/@ember/object/core.ts | 27 +++++++++++++++++++ .../@ember/routing/lib/routing-service.ts | 3 +-- packages/@ember/routing/none-location.ts | 3 +-- packages/@ember/routing/route.ts | 4 +-- packages/@ember/routing/router.ts | 6 ++--- 9 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 26fe4d1b3b9..d9d6560fd63 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -155,11 +155,14 @@ export function deprecation(options: DeprecationOptions, flag?: boolean): Deprec non-deprecating entry point and put the deprecateUntil call only in the public one. The reference pattern is `inject` (public wrapper in @ember/service; internals call metal's `injected_property` directly). - Other examples: `internalExtend`/`internalReopen`/`internalReopenClass` - (@ember/object/core), `createMixin` (@ember/object/mixin), `internalA` - (@ember/array). Include a regression test proving framework operation - (module eval, boot, runtime paths) fires nothing with the deprecation - enabled. + Other examples: `internalExtend` (@ember/object/core), the + `reopenInternal`/`reopenClassInternal` statics (statics rather than module + functions so rollup can scope their side effects to the class — imported + helper calls at module scope make the whole module un-tree-shakable, which + tests/node-vitest/tree-shakability.test.js guards), `createMixin` + (@ember/object/mixin), `internalA` (@ember/array). Include a regression + test proving framework operation (module eval, boot, runtime paths) fires + nothing with the deprecation enabled. */ export const DEPRECATIONS = { DEPRECATE_IMPORT_EMBER(importName: string) { diff --git a/packages/@ember/-internals/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index c79f30a3405..9ded8878584 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -13,7 +13,7 @@ import { getViewElement, } from '@ember/-internals/views/lib/system/utils'; import CoreView from '@ember/-internals/views/lib/views/core_view'; -import { internalExtend, internalReopenClass } from '@ember/object/core'; +import { internalExtend } from '@ember/object/core'; import EventDispatcher from '@ember/-internals/views/lib/system/event_dispatcher'; import { guidFor } from '@ember/-internals/utils/lib/guid'; import { assert } from '@ember/debug'; @@ -1692,7 +1692,7 @@ class Component // We continue to use the reopenClass mechanism here so that positionalParams // can be overridden with reopenClass in subclasses. -internalReopenClass(Component, { +Component.reopenClassInternal({ positionalParams: [], }); diff --git a/packages/@ember/array/proxy.ts b/packages/@ember/array/proxy.ts index 72c1ed65cd4..19906dcbea3 100644 --- a/packages/@ember/array/proxy.ts +++ b/packages/@ember/array/proxy.ts @@ -15,7 +15,6 @@ import { get } from '@ember/-internals/metal/lib/property_get'; import type { PropertyDidChange } from '@ember/-internals/metal/lib/property_events'; import { isObject } from '@ember/-internals/utils/lib/spec'; import EmberObject from '@ember/object'; -import { internalReopen } from '@ember/object/core'; import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import EmberArray, { type NativeArray } from '@ember/array'; import MutableArray from '@ember/array/mutable'; @@ -422,7 +421,7 @@ class ArrayProxy extends EmberObject implements PropertyDidChange { } } -internalReopen(ArrayProxy, MutableArray, { +ArrayProxy.reopenInternal(MutableArray, { arrangedContent: alias('content'), }); diff --git a/packages/@ember/engine/index.ts b/packages/@ember/engine/index.ts index 2bd0eea5756..a626b75ddea 100644 --- a/packages/@ember/engine/index.ts +++ b/packages/@ember/engine/index.ts @@ -3,7 +3,7 @@ export { getEngineParent, setEngineParent } from './parent'; import { canInvoke } from '@ember/-internals/utils/lib/invoke'; import Controller from '@ember/controller'; import Namespace from '@ember/application/namespace'; -import { internalExtend, internalReopenClass } from '@ember/object/core'; +import { internalExtend } from '@ember/object/core'; import Registry from '@ember/-internals/container/lib/registry'; import type { ResolverClass } from '@ember/-internals/container/lib/registry'; import DAG from 'dag-map'; @@ -495,7 +495,7 @@ export function buildInitializerMethod< let attrs = { [bucketName]: Object.create(this[bucketName]), }; - internalReopenClass(this, attrs); + this.reopenClassInternal(attrs); } assert( diff --git a/packages/@ember/object/core.ts b/packages/@ember/object/core.ts index f20372361bc..59d1bfa1b61 100644 --- a/packages/@ember/object/core.ts +++ b/packages/@ember/object/core.ts @@ -847,6 +847,18 @@ class CoreObject { return internalReopen(this, ...args); } + /** + Non-deprecating equivalent of `reopen` for ember-source's own framework + definitions. A static (rather than the module-level `internalReopen`) so + rollup can scope the call's side effect to the class and keep the calling + module tree-shakable. + + @internal + */ + static reopenInternal(this: C, ...args: any[]): C { + return internalReopen(this, ...args); + } + static willReopen() { let p = this.prototype; if (wasApplied.has(p)) { @@ -933,6 +945,21 @@ class CoreObject { return internalReopenClass(this, ...mixins); } + /** + Non-deprecating equivalent of `reopenClass` for ember-source's own + framework definitions. A static (rather than the module-level + `internalReopenClass`) so rollup can scope the call's side effect to the + class and keep the calling module tree-shakable. + + @internal + */ + static reopenClassInternal( + this: C, + ...mixins: Array> + ): C { + return internalReopenClass(this, ...mixins); + } + static detect(obj: unknown) { if ('function' !== typeof obj) { return false; diff --git a/packages/@ember/routing/lib/routing-service.ts b/packages/@ember/routing/lib/routing-service.ts index 2e8569c52d9..5e606169cf6 100644 --- a/packages/@ember/routing/lib/routing-service.ts +++ b/packages/@ember/routing/lib/routing-service.ts @@ -5,7 +5,6 @@ import { getOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; import { readOnly } from '@ember/object/lib/computed/computed_macros'; -import { internalReopen } from '@ember/object/core'; import Service from '@ember/service'; import type { ModelFor } from 'router_js'; import type Route from '@ember/routing/route'; @@ -129,7 +128,7 @@ export default class RoutingService extends Service { } } -internalReopen(RoutingService, { +RoutingService.reopenInternal({ targetState: readOnly('router.targetState'), currentState: readOnly('router.currentState'), currentRouteName: readOnly('router.currentRouteName'), diff --git a/packages/@ember/routing/none-location.ts b/packages/@ember/routing/none-location.ts index b29582b2759..a64bccf2c53 100644 --- a/packages/@ember/routing/none-location.ts +++ b/packages/@ember/routing/none-location.ts @@ -1,5 +1,4 @@ import EmberObject from '@ember/object'; -import { internalReopen } from '@ember/object/core'; import { assert } from '@ember/debug'; import type { default as EmberLocation, UpdateCallback } from '@ember/routing/location'; import { escapeRegExp } from './lib/location-utils'; @@ -128,7 +127,7 @@ export default class NoneLocation extends EmberObject implements EmberLocation { } } -internalReopen(NoneLocation, { +NoneLocation.reopenInternal({ path: '', rootURL: '/', }); diff --git a/packages/@ember/routing/route.ts b/packages/@ember/routing/route.ts index 66ea1cafce2..8b09cfa23be 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -11,7 +11,7 @@ import { set } from '@ember/-internals/metal/lib/property_set'; import getProperties from '@ember/-internals/metal/lib/get_properties'; import setProperties from '@ember/-internals/metal/lib/set_properties'; import EmberObject from '@ember/object'; -import { internalExtend, internalReopen } from '@ember/object/core'; +import { internalExtend } from '@ember/object/core'; import Evented from '@ember/object/evented'; import { internalA as emberA } from '@ember/array'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; @@ -2043,7 +2043,7 @@ export function hasDefaultSerialize(route: Route): boolean { } // Set these here so they can be overridden with extend -internalReopen(Route, { +Route.reopenInternal({ mergedProperties: ['queryParams'], queryParams: {}, templateName: null, diff --git a/packages/@ember/routing/router.ts b/packages/@ember/routing/router.ts index 9188b75edb3..dd0393d2862 100644 --- a/packages/@ember/routing/router.ts +++ b/packages/@ember/routing/router.ts @@ -27,7 +27,7 @@ import type { } from '@ember/routing/location'; import type RouterService from '@ember/routing/router-service'; import EmberObject from '@ember/object'; -import { internalExtend, internalReopen, internalReopenClass } from '@ember/object/core'; +import { internalExtend } from '@ember/object/core'; import { internalA as emberA } from '@ember/array'; import typeOf from '@ember/utils/lib/type-of'; import Evented from '@ember/object/evented'; @@ -255,7 +255,7 @@ class EmberRouter extends internalExtend(EmberObject, Evented) implements Evente if (!this.dslCallbacks) { this.dslCallbacks = []; // FIXME: Can we remove this? - internalReopenClass(this, { dslCallbacks: this.dslCallbacks }); + this.reopenClassInternal({ dslCallbacks: this.dslCallbacks }); } this.dslCallbacks.push(callback); @@ -1815,7 +1815,7 @@ function forEachQueryParam( } } -internalReopen(EmberRouter, { +EmberRouter.reopenInternal({ didTransition: defaultDidTransition, willTransition: defaultWillTransition, rootURL: '/',