diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index 716cdf97e3d..262e2077b08 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 }} @@ -209,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..c84f8664de6 --- /dev/null +++ b/bin/assert-deprecations-shaken.mjs @@ -0,0 +1,189 @@ +/* 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. 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 + // 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'; +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..925e46c39c6 --- /dev/null +++ b/broccoli/deprecated-features.cjs @@ -0,0 +1,95 @@ +'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', + }), + 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( + 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/index.html b/index.html index ba2b0bcf2fe..8ad00a7da9e 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,26 @@ EmberENV['_OVERRIDE_DEPRECATION_VERSION'] = QUnit.urlParams.OVERRIDE_DEPRECATION_VERSION; } + 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 = + QUnit.urlParams.ENABLED_DEPRECATIONS === 'true' + ? true + : QUnit.urlParams.ENABLED_DEPRECATIONS.split(','); + } + 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({ id: 'OVERRIDE_DEPRECATION_VERSION', value: ['20.0.0', '8.0.0', '7.12.0', '6.0.0', '5.12.0'], 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..cf32ea1e281 --- /dev/null +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -0,0 +1,348 @@ +--- +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 this configuration treats as unconfigured — exempted + * from `compliance`/`assert` throwing and excluded from `enable` + * (including `enable: true`). + */ + 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`, and `except` also excludes + 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. +- `_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 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 + +#### 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. 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'; + +const Comparable = DEPRECATE_COMPARABLE_MIXIN + ? Mixin.create({ + init() { + deprecateUntil(msg, DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN); + // ... + }, + compare: null, + }) + : 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); + } +} +``` + +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 +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. + +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 + (`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. +- **`@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 + 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 `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. diff --git a/lib/deprecation-shaking/index.js b/lib/deprecation-shaking/index.js new file mode 100644 index 00000000000..62315b663af --- /dev/null +++ b/lib/deprecation-shaking/index.js @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const emberSourceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +// 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). +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. + let path = id.split('?')[0].replace(/\\/g, '/'); + if (path.endsWith(FLAGS_MODULE_SUFFIX) && path.includes('/dist/')) { + return code; + } + }, + }; +} diff --git a/package.json b/package.json index 0cd339955fd..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/*", @@ -246,6 +247,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/packages/@ember/-internals/deprecations/index.ts b/packages/@ember/-internals/deprecations/index.ts index ef1578e4618..c8495807122 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -1,11 +1,25 @@ import type { DeprecationOptions } from '@ember/debug/lib/deprecate'; +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'; +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) { - 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 +41,31 @@ 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). +// +// `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. +// +// `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, - test: !isEnabled(options), - isEnabled: isEnabled(options) || isRemoved(options), - isRemoved: isRemoved(options), + get test() { + return !isEnabled(options); + }, + get isEnabled() { + return isEnabled(options) || this.isRemoved; + }, + get isRemoved() { + return (isRemoved(options) && !isDeprecationExceptedByConfig(options.id)) || flag === false; + }, }; } @@ -89,6 +122,35 @@ function deprecation(options: DeprecationOptions) { 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). 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) { @@ -102,23 +164,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', @@ -147,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/deprecations/tests/index-test.js b/packages/@ember/-internals/deprecations/tests/index-test.js index 5d3d0efea36..4860403515f 100644 --- a/packages/@ember/-internals/deprecations/tests/index-test.js +++ b/packages/@ember/-internals/deprecations/tests/index-test.js @@ -1,6 +1,9 @@ -import { AbstractTestCase, moduleFor } from 'internal-test-helpers'; -import { deprecateUntil, isRemoved, emberVersionGte } from '../index'; +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'; +import * as DEPRECATED_FEATURES from '@ember/deprecated-features'; +import deprecatedFeaturesManifest from '../../../../../broccoli/deprecated-features.cjs'; let originalEnvValue; @@ -14,9 +17,64 @@ moduleFor( } teardown() { + setDeprecationStagesConfig(null); 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 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', + 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 deprecateUntil throws when deprecation has been removed'](assert) { assert.expect(1); @@ -44,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', @@ -135,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'); + } + } +); 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/-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/-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/-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/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..be4920dc5db --- /dev/null +++ b/packages/@ember/debug/lib/deprecation-stages.ts @@ -0,0 +1,194 @@ +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 this configuration should treat as unconfigured: exempted from + `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 = () => {}; + +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 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; + } + 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; + }; + + // 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 ?? bootConfig); + }; +} + +export { + isDeprecationEnabledByConfig, + isDeprecationExceptedByConfig, + 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..986c74b69f7 --- /dev/null +++ b/packages/@ember/debug/tests/deprecation-stages-test.js @@ -0,0 +1,215 @@ +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 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')); + 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 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', + 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/packages/@ember/deprecated-features/index.ts b/packages/@ember/deprecated-features/index.ts index ed08aa1ff85..dd671d58cc6 100644 --- a/packages/@ember/deprecated-features/index.ts +++ b/packages/@ember/deprecated-features/index.ts @@ -1,5 +1,22 @@ -// 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; + +/** 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/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 8e62d957f1e..a2b9e482cc5 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 @@ -649,6 +652,9 @@ importers: '@ember/utils': specifier: workspace:* version: link:../utils + '@ember/version': + specifier: workspace:* + version: link:../version '@glimmer/destroyable': specifier: workspace:* version: link:../../@glimmer/destroyable @@ -876,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 @@ -1087,6 +1096,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 diff --git a/rollup.config.mjs b/rollup.config.mjs index 23b639ceceb..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'; @@ -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,52 @@ 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, + })); + // 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' + ); + }, + }; +} + function pruneEmptyBundles() { return { name: 'prune-empty-bundles', diff --git a/smoke-tests/scenarios/deprecation-shaking-test.ts b/smoke-tests/scenarios/deprecation-shaking-test.ts new file mode 100644 index 00000000000..f39ab750d21 --- /dev/null +++ b/smoke-tests/scenarios/deprecation-shaking-test.ts @@ -0,0 +1,165 @@ +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; + +// 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')]; + 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', + 'deprecate-object-proxy', + 'deprecate-promise-proxy-mixin', + ], + }), + ] + : []), + 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, + 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 + // 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'); + // 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( + DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN.isRemoved, + 'reports removed when the flag is off' + ); + } + }); + + 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/); + }); + }); + `, + }, + }, + }); + }) + .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 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); + 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); + 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); + for (let marker of MARKERS) { + assert.false( + distContains(app.dir, marker), + `marker absent from shaken prod build: ${marker}` + ); + } + }); + }); + }); diff --git a/testem.cjs b/testem.cjs index 201511b862a..3c95a011ada 100644 --- a/testem.cjs +++ b/testem.cjs @@ -11,6 +11,20 @@ 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', + + // 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', 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', 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`); + } + }); +});