From c18f46ffa4a31ab42ba3d3868d8c903f5a6b1702 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:10:06 -0700 Subject: [PATCH 01/15] 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 40769e2f92bde46e57a3be13a7607974c4278dd3 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:22:43 -0700 Subject: [PATCH 02/15] 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 ba2b0bcf2fe..af65bd5f7f5 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', '8.0.0', '7.12.0', '6.0.0', '5.12.0'], diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index ef1578e4618..ce13c0dc636 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 8e62d957f1e..1e9a13719dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -649,6 +649,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 697e4b8ed32..d7ee4eb38de 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 f07c32edbf32b203641858137f515506470f8041 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:28:51 -0700 Subject: [PATCH 03/15] 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 ce13c0dc636..8d5b1e24412 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', }, - 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_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', + }, + DEPRECATE_COMPARABLE_MIXIN + ), DEPRECATE_TARGET_ACTION_SUPPORT: deprecation({ for: 'ember-source', id: 'deprecate-target-action-support', 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 20047cfcb00..eb0fcacb52f 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts @@ -1,6 +1,7 @@ import Mixin from '@ember/object/mixin'; import { INTERNAL_MIXIN_CREATE } from '@ember/-internals/utils/lib/internal-mixin-create'; import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; +import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; /** @module ember @@ -20,34 +21,36 @@ import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; interface Comparable { compare: ((a: unknown, b: unknown) => -1 | 0 | 1) | null; } -const Comparable = Mixin[INTERNAL_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[INTERNAL_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 1e9a13719dc..8a1e8eb9150 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,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 @@ -1090,6 +1093,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 0d0c3d7027c20b7523f193515bab087aec8a2c57 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:39:40 -0700 Subject: [PATCH 04/15] 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 0cd339955fd..a80e18f2ae6 100644 --- a/package.json +++ b/package.json @@ -246,6 +246,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 23b639ceceb..e7363df57b7 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', @@ -33,6 +34,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), @@ -72,8 +86,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, @@ -81,6 +96,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', @@ -90,12 +111,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 { @@ -406,11 +435,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; } @@ -424,6 +454,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 @@ -512,6 +552,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 ce6350b6a184f51d5edee34b44b6f95c13d5be9a Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:46:43 -0700 Subject: [PATCH 05/15] 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 a80e18f2ae6..1128ba54ed5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ ], "type": "module", "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 2d9310c35fed4220a5ee849e655af96579c38a94 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 13:49:08 -0700 Subject: [PATCH 06/15] 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 8d5b1e24412..5aa482d633d 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 366cbeb676423acdbc0a66e9f3708a48f3610896 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 14:15:06 -0700 Subject: [PATCH 07/15] 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 1144e9484d516581ad4cbcffcd59cd46715f9505 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 14:21:19 -0700 Subject: [PATCH 08/15] 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 f33e50ae74c7db9f885e466ee524101ebb2797da Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 16:59:04 -0700 Subject: [PATCH 09/15] 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 5448f5876ff07cd76dc762c460ad0426d255af46 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 20:04:02 -0700 Subject: [PATCH 10/15] 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 e7363df57b7..a658e87214f 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'; @@ -587,6 +587,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 c7e563bb6f191285afc1ef29be4f1273c8840903 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 20:31:18 -0700 Subject: [PATCH 11/15] 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 d8e5119027a680ba1ae5d23e2dda06c606128e7a Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 15:24:20 -0700 Subject: [PATCH 12/15] 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 af65bd5f7f5..8ad00a7da9e 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 2cc3f843385210cadaf16a8103d45492cbf5d70b Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Fri, 18 Sep 2026 21:01:03 +0200 Subject: [PATCH 13/15] Shield excepted ids from the removal simulation except now also excludes an id from the version-based removal computation, so _OVERRIDE_DEPRECATION_VERSION can simulate a future version with known-noisy ids excluded, the same way enable: true coexists with them. It never shields a false shaking flag: that code is actually gone. setDeprecationStagesConfig(null) now restores the boot (EmberENV) config instead of clearing it, so test teardowns no longer wipe the harness variant's config for the rest of the suite. {} is an explicitly empty config. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Opus 5 (1M context) --- ...eprecation-early-enablement-and-shaking.md | 8 +++-- .../@ember/-internals/deprecations/index.ts | 14 ++++++-- .../deprecations/tests/index-test.js | 34 +++++++++++++++++-- .../@ember/debug/lib/deprecation-stages.ts | 21 +++++++++--- .../debug/tests/deprecation-stages-test.js | 4 +-- 5 files changed, 66 insertions(+), 15 deletions(-) diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index 545aa07e7da..561e8c4dd32 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 5aa482d633d..0508de16d6d 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,11 @@ 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` also shields an id from the version-based removal computation, +// including the _OVERRIDE_DEPRECATION_VERSION simulation, so a simulated +// future version can run with known-noisy ids excluded. 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 +56,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/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')); From d1afb0ffe8c98078eca0e81facb97a9c0d4da789 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Thu, 16 Jul 2026 20:11:34 -0700 Subject: [PATCH 14/15] 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 dded12fbca57b63442d62244b2284cd3eeb45837 Mon Sep 17 00:00:00 2001 From: Peter Wagenet Date: Fri, 18 Sep 2026 21:06:11 +0200 Subject: [PATCH 15/15] Make the ObjectProxy and PromiseProxyMixin deprecations shakable Both are already deprecated (RFC 1112) and nothing in ember-source uses them, so their implementations can leave a shaken build. PromiseProxyMixin takes the value shape, like Comparable: a shaken build exports undefined. A cast keeps its published type, since TypeScript does not narrow a const-true flag in a conditional. ObjectProxy takes the entrypoint shape: the class survives as a stub whose init throws the removal error, and ProxyMixin, which holds the forwarding behavior and has no other consumer, is shaken away. Co-Authored-By: Claude Opus 5 (1M context) --- bin/assert-deprecations-shaken.mjs | 5 + broccoli/deprecated-features.cjs | 10 ++ ...eprecation-early-enablement-and-shaking.md | 7 ++ .../@ember/-internals/deprecations/index.ts | 41 ++++--- .../-internals/runtime/lib/mixins/-proxy.ts | 113 ++++++++++-------- packages/@ember/deprecated-features/index.ts | 6 + packages/@ember/object/package.json | 1 + packages/@ember/object/promise-proxy-mixin.ts | 85 +++++++------ packages/@ember/object/proxy.ts | 7 +- pnpm-lock.yaml | 3 + .../scenarios/deprecation-shaking-test.ts | 56 +++++++-- 11 files changed, 217 insertions(+), 117 deletions(-) diff --git a/bin/assert-deprecations-shaken.mjs b/bin/assert-deprecations-shaken.mjs index b22aeb8bb0e..c84f8664de6 100644 --- a/bin/assert-deprecations-shaken.mjs +++ b/bin/assert-deprecations-shaken.mjs @@ -35,6 +35,11 @@ const CONTENT_MARKERS = { // intentionally survives shaking as the throwing stub. The flag identifier // check still proves the guarded implementation was folded away. DEPRECATE_IMPORT_INJECT: [], + // DEPRECATE_OBJECT_PROXY has no content marker: ObjectProxy's deprecateUntil + // message survives as the throwing stub, and ProxyMixin has no runtime + // string that is unique to it. + DEPRECATE_OBJECT_PROXY: [], + DEPRECATE_PROMISE_PROXY_MIXIN: ['`PromiseProxyMixin` is deprecated'], }; const FLAGS_MODULE_SUFFIX = 'packages/@ember/deprecated-features/index.js'; diff --git a/broccoli/deprecated-features.cjs b/broccoli/deprecated-features.cjs index fb56746b538..925e46c39c6 100644 --- a/broccoli/deprecated-features.cjs +++ b/broccoli/deprecated-features.cjs @@ -15,6 +15,16 @@ const FLAGS = Object.freeze({ since: Object.freeze({ available: '6.2.0', enabled: '6.3.0' }), until: '7.0.0', }), + DEPRECATE_OBJECT_PROXY: Object.freeze({ + id: 'deprecate-object-proxy', + since: Object.freeze({ available: '7.4.0' }), + until: '8.0.0', + }), + DEPRECATE_PROMISE_PROXY_MIXIN: Object.freeze({ + id: 'deprecate-promise-proxy-mixin', + since: Object.freeze({ available: '7.4.0' }), + until: '8.0.0', + }), }); const DEFAULT_FLAGS = Object.freeze( diff --git a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md index 561e8c4dd32..cf32ea1e281 100644 --- a/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -203,6 +203,13 @@ export function inject(...args) { } ``` +A deprecated public class takes the entrypoint shape: the class survives +shaking as a stub whose `init` throws the removal error, and the behavior it +composes is guarded (`ObjectProxy` keeps its class; its `ProxyMixin` is +shaken away). A deprecated public value export takes the value shape but +keeps its published type with a cast, since TypeScript does not narrow a +`const X = true` flag in a conditional (`PromiseProxyMixin`). + 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 diff --git a/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index 0508de16d6d..c8495807122 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -6,7 +6,12 @@ import { 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 { + DEPRECATE_COMPARABLE_MIXIN, + DEPRECATE_IMPORT_INJECT, + DEPRECATE_OBJECT_PROXY, + DEPRECATE_PROMISE_PROXY_MIXIN, +} from '@ember/deprecated-features'; import { dasherize } from '../string/index'; function isEnabled(options: DeprecationOptions) { @@ -210,20 +215,26 @@ export const DEPRECATIONS = { until: '8.0.0', url: 'https://deprecations.emberjs.com/id/deprecate-array-proxy', }), - DEPRECATE_OBJECT_PROXY: deprecation({ - id: 'deprecate-object-proxy', - for: 'ember-source', - since: { available: '7.4.0' }, - until: '8.0.0', - url: 'https://deprecations.emberjs.com/id/deprecate-object-proxy', - }), - DEPRECATE_PROMISE_PROXY_MIXIN: deprecation({ - id: 'deprecate-promise-proxy-mixin', - for: 'ember-source', - since: { available: '7.4.0' }, - until: '8.0.0', - url: 'https://deprecations.emberjs.com/id/deprecate-promise-proxy-mixin', - }), + DEPRECATE_OBJECT_PROXY: deprecation( + { + id: 'deprecate-object-proxy', + for: 'ember-source', + since: { available: '7.4.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-object-proxy', + }, + DEPRECATE_OBJECT_PROXY + ), + DEPRECATE_PROMISE_PROXY_MIXIN: deprecation( + { + id: 'deprecate-promise-proxy-mixin', + for: 'ember-source', + since: { available: '7.4.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-promise-proxy-mixin', + }, + DEPRECATE_PROMISE_PROXY_MIXIN + ), }; export function deprecateUntil(message: string, deprecation: DeprecationObject) { diff --git a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts index cf3e23234e6..b03a9251e50 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts @@ -14,6 +14,7 @@ import { setProxy, isProxy } from '@ember/-internals/utils/lib/is_proxy'; import { setupMandatorySetter } from '@ember/-internals/utils/lib/mandatory-setter'; import { isObject } from '@ember/-internals/utils/lib/spec'; import { assert } from '@ember/debug'; +import { DEPRECATE_OBJECT_PROXY } from '@ember/deprecated-features'; import { DEBUG } from '@glimmer/env'; import { setCustomTagFor } from '@glimmer/manager/lib/util/args-proxy'; import type { UpdatableTag, Tag } from '@glimmer/interfaces'; @@ -91,58 +92,64 @@ interface ProxyMixin { setUnknownProperty(key: string, value: V): V; } -const ProxyMixin = /*@__PURE__*/ Mixin[INTERNAL_MIXIN_CREATE]({ - /** - The object whose properties will be forwarded. - - @property content - @type {unknown} - @default null - @public - */ - content: null, - - init() { - this._super(...arguments); - setProxy(this); - tagForObject(this); - setCustomTagFor(this, customTagForProxy); - }, - - willDestroy() { - this.set('content', null); - this._super(...arguments); - }, - - isTruthy: computed('content', function () { - return Boolean(get(this, 'content')); - }), - - unknownProperty(key: string) { - let content = contentFor(this); - return content ? get(content, key) : undefined; - }, - - setUnknownProperty(key: string, value: unknown) { - let m = meta(this); - - if (m.isInitializing() || m.isPrototypeMeta(this)) { - // if marked as prototype or object is initializing then just - // defineProperty rather than delegate - defineProperty(this, key, null, value); - return value; - } - - let content = contentFor(this); - - assert( - `Cannot delegate set('${key}', ${value}) to the 'content' property of object proxy ${this}: its 'content' is undefined.`, - content - ); - - // SAFETY: We don't actually guarantee that this is an object, so this isn't necessarily safe :( - return set(content as object, key, value); - }, -}); +// SAFETY: ObjectProxy is ProxyMixin's only consumer, so the mixin shakes +// with it; a shaken build exports undefined. +const ProxyMixin = ( + DEPRECATE_OBJECT_PROXY + ? /*@__PURE__*/ Mixin[INTERNAL_MIXIN_CREATE]({ + /** + The object whose properties will be forwarded. + + @property content + @type {unknown} + @default null + @public + */ + content: null, + + init() { + this._super(...arguments); + setProxy(this); + tagForObject(this); + setCustomTagFor(this, customTagForProxy); + }, + + willDestroy() { + this.set('content', null); + this._super(...arguments); + }, + + isTruthy: computed('content', function () { + return Boolean(get(this, 'content')); + }), + + unknownProperty(key: string) { + let content = contentFor(this); + return content ? get(content, key) : undefined; + }, + + setUnknownProperty(key: string, value: unknown) { + let m = meta(this); + + if (m.isInitializing() || m.isPrototypeMeta(this)) { + // if marked as prototype or object is initializing then just + // defineProperty rather than delegate + defineProperty(this, key, null, value); + return value; + } + + let content = contentFor(this); + + assert( + `Cannot delegate set('${key}', ${value}) to the 'content' property of object proxy ${this}: its 'content' is undefined.`, + content + ); + + // SAFETY: We don't actually guarantee that this is an object, so this isn't necessarily safe :( + return set(content as object, key, value); + }, + }) + : undefined +) as Mixin; export default ProxyMixin; diff --git a/packages/@ember/deprecated-features/index.ts b/packages/@ember/deprecated-features/index.ts index 8ca0dc3109d..dd671d58cc6 100644 --- a/packages/@ember/deprecated-features/index.ts +++ b/packages/@ember/deprecated-features/index.ts @@ -14,3 +14,9 @@ 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; + +/** id: deprecate-object-proxy, since: 7.4.0, until: 8.0.0 */ +export const DEPRECATE_OBJECT_PROXY = true; + +/** id: deprecate-promise-proxy-mixin, since: 7.4.0, until: 8.0.0 */ +export const DEPRECATE_PROMISE_PROXY_MIXIN = true; diff --git a/packages/@ember/object/package.json b/packages/@ember/object/package.json index e0c4ce1ff36..109e1753ab6 100644 --- a/packages/@ember/object/package.json +++ b/packages/@ember/object/package.json @@ -23,6 +23,7 @@ "@ember/application": "workspace:*", "@ember/array": "workspace:*", "@ember/debug": "workspace:*", + "@ember/deprecated-features": "workspace:*", "@ember/enumerable": "workspace:*", "@ember/runloop": "workspace:*", "@ember/service": "workspace:*", diff --git a/packages/@ember/object/promise-proxy-mixin.ts b/packages/@ember/object/promise-proxy-mixin.ts index 977d645b68c..5f6a88fe52a 100644 --- a/packages/@ember/object/promise-proxy-mixin.ts +++ b/packages/@ember/object/promise-proxy-mixin.ts @@ -4,6 +4,7 @@ import computed from '@ember/-internals/metal/lib/computed'; import Mixin from '@ember/object/mixin'; import { INTERNAL_MIXIN_CREATE } from '@ember/-internals/utils/lib/internal-mixin-create'; import { DEPRECATIONS, deprecateUntil } from '@ember/-internals/deprecations'; +import { DEPRECATE_PROMISE_PROXY_MIXIN } from '@ember/deprecated-features'; import type { AnyFn, MethodNamesOf } from '@ember/-internals/utility-types'; import type RSVP from 'rsvp'; import type CoreObject from '@ember/object/core'; @@ -214,45 +215,51 @@ interface PromiseProxyMixin { */ finally: this['promise']['finally']; } -const PromiseProxyMixin = Mixin[INTERNAL_MIXIN_CREATE]({ - init() { - this._super(...arguments); - - deprecateUntil( - '`PromiseProxyMixin` is deprecated. Use native `async`/`await` and promises directly, tracking the loading state on your own class instead.', - DEPRECATIONS.DEPRECATE_PROMISE_PROXY_MIXIN - ); - }, - - reason: null, - - isPending: computed('isSettled', function () { - return !get(this, 'isSettled'); - }).readOnly(), - - isSettled: computed('isRejected', 'isFulfilled', function () { - return get(this, 'isRejected') || get(this, 'isFulfilled'); - }).readOnly(), - - isRejected: false, - - isFulfilled: false, - - promise: computed({ - get() { - throw new Error("PromiseProxy's promise must be set"); - }, - set(_key, promise: RSVP.Promise) { - return tap(this, promise); - }, - }), - - then: promiseAlias('then'), - - catch: promiseAlias('catch'), - - finally: promiseAlias('finally'), -}); +// SAFETY: a shaken build (flag false) exports undefined, the documented +// rendering of a removed value export; the public type stays the mixin. +const PromiseProxyMixin = ( + DEPRECATE_PROMISE_PROXY_MIXIN + ? Mixin[INTERNAL_MIXIN_CREATE]({ + init() { + this._super(...arguments); + + deprecateUntil( + '`PromiseProxyMixin` is deprecated. Use native `async`/`await` and promises directly, tracking the loading state on your own class instead.', + DEPRECATIONS.DEPRECATE_PROMISE_PROXY_MIXIN + ); + }, + + reason: null, + + isPending: computed('isSettled', function () { + return !get(this, 'isSettled'); + }).readOnly(), + + isSettled: computed('isRejected', 'isFulfilled', function () { + return get(this, 'isRejected') || get(this, 'isFulfilled'); + }).readOnly(), + + isRejected: false, + + isFulfilled: false, + + promise: computed({ + get() { + throw new Error("PromiseProxy's promise must be set"); + }, + set(_key, promise: RSVP.Promise) { + return tap(this, promise); + }, + }), + + then: promiseAlias('then'), + + catch: promiseAlias('catch'), + + finally: promiseAlias('finally'), + }) + : undefined +) as Mixin; function promiseAlias>>(name: N) { return function (this: PromiseProxyMixin, ...args: Parameters[N]>) { diff --git a/packages/@ember/object/proxy.ts b/packages/@ember/object/proxy.ts index be037bfc153..cb33fb2393b 100644 --- a/packages/@ember/object/proxy.ts +++ b/packages/@ember/object/proxy.ts @@ -5,6 +5,7 @@ import { FrameworkObject } from '@ember/object/-internals'; import _ProxyMixin from '@ember/-internals/runtime/lib/mixins/-proxy'; import { DEPRECATIONS, deprecateUntil } from '@ember/-internals/deprecations'; +import { DEPRECATE_OBJECT_PROXY } from '@ember/deprecated-features'; /** `ObjectProxy` forwards all properties not defined by the proxy itself @@ -132,6 +133,10 @@ class ObjectProxy extends FrameworkObject { ); } } -ObjectProxy.PrototypeMixin.reopen(_ProxyMixin); +// Entrypoint shape: the class survives shaking as a stub whose init throws the +// removal error; the forwarding behavior in ProxyMixin is shaken away. The +// guard sits inside the call because an `if` around a module-scope call makes +// the whole module un-tree-shakable (tests/node-vitest/tree-shakability). +ObjectProxy.PrototypeMixin.reopen(DEPRECATE_OBJECT_PROXY ? _ProxyMixin : {}); export default ObjectProxy; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a1e8eb9150..a2b9e482cc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -882,6 +882,9 @@ importers: '@ember/debug': specifier: workspace:* version: link:../debug + '@ember/deprecated-features': + specifier: workspace:* + version: link:../deprecated-features '@ember/enumerable': specifier: workspace:* version: link:../enumerable diff --git a/smoke-tests/scenarios/deprecation-shaking-test.ts b/smoke-tests/scenarios/deprecation-shaking-test.ts index 5353c677261..f39ab750d21 100644 --- a/smoke-tests/scenarios/deprecation-shaking-test.ts +++ b/smoke-tests/scenarios/deprecation-shaking-test.ts @@ -5,9 +5,9 @@ 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'; +// Runtime strings inside shaken branches — present in a normal build, gone +// from a shaken one. +const MARKERS = ['The `Comparable` mixin is deprecated', '`PromiseProxyMixin` is deprecated']; function distContains(appDir: string, text: string): boolean { let queue = [join(appDir, 'dist')]; @@ -50,7 +50,12 @@ Scenarios.fromProject(() => ...(process.env.SHAKE ? [ deprecationShaking({ - strip: ['deprecate-comparable-mixin', 'importing-inject-from-ember-service'], + strip: [ + 'deprecate-comparable-mixin', + 'importing-inject-from-ember-service', + 'deprecate-object-proxy', + 'deprecate-promise-proxy-mixin', + ], }), ] : []), @@ -67,8 +72,14 @@ Scenarios.fromProject(() => 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 { + DEPRECATE_COMPARABLE_MIXIN, + DEPRECATE_OBJECT_PROXY, + DEPRECATE_PROMISE_PROXY_MIXIN, + } from '@ember/deprecated-features'; import { inject } from '@ember/service'; + import ObjectProxy from '@ember/object/proxy'; + import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; module('deprecation shaking', function () { // Passes in both the shaken and unshaken build: the runtime @@ -88,6 +99,24 @@ Scenarios.fromProject(() => } }); + test('proxy flags are consistent with runtime behavior', function (assert) { + if (DEPRECATE_PROMISE_PROXY_MIXIN) { + assert.true(Boolean(PromiseProxyMixin), 'PromiseProxyMixin exists while the flag is on'); + } else { + assert.strictEqual(PromiseProxyMixin, undefined, 'PromiseProxyMixin is shaken away'); + } + + if (!DEPRECATE_OBJECT_PROXY) { + assert.throws( + () => ObjectProxy.create({ content: {} }), + /was removed in ember-source/, + 'the ObjectProxy stub throws the removal error' + ); + } else { + assert.true(typeof ObjectProxy === 'function', 'ObjectProxy exists while the flag is on'); + } + }); + test('an API past its until version throws the removal error', function (assert) { assert.throws(() => inject('foo'), /was removed in ember-source/); }); @@ -105,23 +134,32 @@ Scenarios.fromProject(() => }); // Control: without the plugin the deprecated code ships (also proves - // the MARKER stays a valid probe). + // the MARKERS stay valid probes). 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'); + for (let marker of MARKERS) { + assert.true(distContains(app.dir, marker), `marker present in unshaken build: ${marker}`); + } }); 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'); + for (let marker of MARKERS) { + assert.false(distContains(app.dir, marker), `marker absent from shaken build: ${marker}`); + } }); 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'); + for (let marker of MARKERS) { + assert.false( + distContains(app.dir, marker), + `marker absent from shaken prod build: ${marker}` + ); + } }); }); });