diff --git a/.github/workflows/ci-jobs.yml b/.github/workflows/ci-jobs.yml index 716cdf97e3d..40d823ad816 100644 --- a/.github/workflows/ci-jobs.yml +++ b/.github/workflows/ci-jobs.yml @@ -99,15 +99,29 @@ jobs: fail-fast: false matrix: include: + # The classic object model deprecations are excluded from the + # blanket rows: ember's own test suite (and internal-test-helpers) + # still exercises those APIs pervasively. They get targeted + # coverage in packages/@ember/object/tests instead. - name: "All deprecations enabled" - ALL_DEPRECATIONS_ENABLED: "true" + ENABLED_DEPRECATIONS: "true" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" - name: "All deprecations enabled, with optional features" - ALL_DEPRECATIONS_ENABLED: "true" + ENABLED_DEPRECATIONS: "true" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" ENABLE_OPTIONAL_FEATURES: "true" + - name: "Deprecation compliance declared" + DEPRECATION_COMPLIANCE: "7.2.0" + RAISE_ON_DEPRECATION: "false" + # The classic ids are excepted here too: except also shields an id + # from the OVERRIDE_DEPRECATION_VERSION removal simulation, and the + # suite exercises the classic APIs pervasively. - name: "Deprecations as errors" OVERRIDE_DEPRECATION_VERSION: "15.0.0" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" - name: "Deprecations as errors, with optional features" OVERRIDE_DEPRECATION_VERSION: "15.0.0" + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" ENABLE_OPTIONAL_FEATURES: "true" - name: "Production build" BUILD: "production" @@ -126,7 +140,9 @@ jobs: NODE_ENV: ${{ matrix.BUILD || 'development' }} - name: test env: - ALL_DEPRECATIONS_ENABLED: ${{ matrix.ALL_DEPRECATIONS_ENABLED }} + ENABLED_DEPRECATIONS: ${{ matrix.ENABLED_DEPRECATIONS }} + EXCEPT_DEPRECATIONS: ${{ matrix.EXCEPT_DEPRECATIONS }} + DEPRECATION_COMPLIANCE: ${{ matrix.DEPRECATION_COMPLIANCE }} OVERRIDE_DEPRECATION_VERSION: ${{ matrix.OVERRIDE_DEPRECATION_VERSION }} ENABLE_OPTIONAL_FEATURES: ${{ matrix.ENABLE_OPTIONAL_FEATURES }} RAISE_ON_DEPRECATION: ${{ matrix.RAISE_ON_DEPRECATION }} @@ -204,11 +220,35 @@ jobs: - name: test env: OVERRIDE_DEPRECATION_VERSION: "15.0.0" + # The classic ids are shielded from the removal simulation: the + # app-testing ecosystem (@ember/test-helpers, ember-qunit) still + # uses the classic APIs. + EXCEPT_DEPRECATIONS: "deprecate-ember-object-extend,deprecate-ember-object-reopen,deprecate-ember-mixins,deprecate-computed-properties,deprecate-observers,deprecate-ember-array,deprecate-object-proxy,deprecate-array-proxy" MATRIX_COMMAND: ${{ matrix.command }} working-directory: smoke-tests/scenarios run: | ${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..b22aeb8bb0e --- /dev/null +++ b/bin/assert-deprecations-shaken.mjs @@ -0,0 +1,184 @@ +/* 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: [], +}; + +const FLAGS_MODULE_SUFFIX = 'packages/@ember/deprecated-features/index.js'; +const SELF_REFERENCE = 'ember-source/@ember/deprecated-features/index.js'; + +let failures = []; + +function stripComments(code) { + return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); +} + +function walkJs(dir, found = []) { + for (let entry of readdirSync(dir, { withFileTypes: true })) { + let full = join(dir, entry.name); + if (entry.isDirectory()) { + walkJs(full, found); + } else if (entry.name.endsWith('.js')) { + found.push(full); + } + } + return found; +} + +function checkShakenDist(disabledFlags) { + let distDir = join(projectRoot, 'dist/deprecation-custom/prod'); + if (!existsSync(distDir)) { + failures.push(`missing ${distDir} — build with EMBER_DEPRECATION_FLAGS first`); + return; + } + + for (let file of walkJs(distDir)) { + if (file.endsWith(FLAGS_MODULE_SUFFIX)) { + let code = readFileSync(file, 'utf8'); + for (let flag of disabledFlags) { + if (!new RegExp(`${flag}\\s*=\\s*false`).test(code)) { + failures.push(`${file}: expected ${flag} = false in shaken flags module`); + } + } + continue; + } + + let code = stripComments(readFileSync(file, 'utf8')); + for (let flag of disabledFlags) { + // A flag name may legitimately survive as a DEPRECATIONS registry key + // (`DEPRECATE_X:`) or property access (`DEPRECATIONS.DEPRECATE_X`) — + // those are the runtime registry, not the folded import. Only a + // standalone binding reference means folding failed. + if (new RegExp(`(? ({ + const: name, + id, + since, + until, + })); + if (JSON.stringify(meta) !== JSON.stringify(expected)) { + failures.push(`${metaPath} does not match broccoli/deprecated-features.cjs FLAGS manifest`); + } + } +} + +let disabledFlags = Object.keys(DEFAULT_FLAGS); +if (process.env.EMBER_DEPRECATION_FLAGS) { + let resolved = parseFlagsFromEnv(process.env.EMBER_DEPRECATION_FLAGS); + disabledFlags = Object.keys(resolved).filter((name) => resolved[name] === false); +} + +checkShakenDist(disabledFlags); +checkStandardDist(); + +if (failures.length > 0) { + console.log(`assert-deprecations-shaken: ${failures.length} problem(s):`); + for (let failure of failures) { + console.log(` - ${failure}`); + } + if (!report) { + throw new Error(`assert-deprecations-shaken found ${failures.length} problem(s)`); + } +} else { + console.log( + `assert-deprecations-shaken: OK (${disabledFlags.length} flag(s) verified shaken; standard dist verified live)` + ); +} diff --git a/broccoli/deprecated-features.cjs b/broccoli/deprecated-features.cjs new file mode 100644 index 00000000000..fb56746b538 --- /dev/null +++ b/broccoli/deprecated-features.cjs @@ -0,0 +1,85 @@ +'use strict'; + +// Canonical build-time manifest of shakable deprecations. Each key must match +// both an `export const = true` in packages/@ember/deprecated-features +// and a DEPRECATIONS registry key in @ember/-internals/deprecations (a +// conformance test enforces the latter pairing). +const FLAGS = Object.freeze({ + DEPRECATE_COMPARABLE_MIXIN: Object.freeze({ + id: 'deprecate-comparable-mixin', + since: Object.freeze({ available: '7.2.0', enabled: '7.2.0' }), + until: '7.5.0', + }), + DEPRECATE_IMPORT_INJECT: Object.freeze({ + id: 'importing-inject-from-ember-service', + since: Object.freeze({ available: '6.2.0', enabled: '6.3.0' }), + until: '7.0.0', + }), +}); + +const DEFAULT_FLAGS = Object.freeze( + Object.fromEntries(Object.keys(FLAGS).map((name) => [name, true])) +); + +function resolveFlags(overrides = {}) { + for (let [name, value] of Object.entries(overrides)) { + if (!(name in DEFAULT_FLAGS)) { + throw new Error( + `Unknown deprecation flag: ${name}. Valid flags: ${Object.keys(DEFAULT_FLAGS).join(', ')}` + ); + } + if (typeof value !== 'boolean') { + throw new Error(`Deprecation flag ${name} must be a boolean, got: ${value}`); + } + } + return { ...DEFAULT_FLAGS, ...overrides }; +} + +// Parses EMBER_DEPRECATION_FLAGS, e.g. +// "DEPRECATE_COMPARABLE_MIXIN=false,DEPRECATE_IMPORT_INJECT=false", with +// "all=false" as shorthand for disabling every flag. +function parseFlagsFromEnv(value) { + let overrides = {}; + for (let entry of value.split(',')) { + let trimmed = entry.trim(); + if (trimmed === '') continue; + let match = /^(\w+)=(true|false)$/.exec(trimmed); + if (!match) { + throw new Error( + `Cannot parse EMBER_DEPRECATION_FLAGS entry: "${trimmed}" (expected NAME=true or NAME=false)` + ); + } + if (match[1] === 'all') { + for (let name of Object.keys(DEFAULT_FLAGS)) { + overrides[name] = match[2] === 'true'; + } + } else { + overrides[match[1]] = match[2] === 'true'; + } + } + return resolveFlags(overrides); +} + +// babel-plugin-debug-macros tuple that folds @ember/deprecated-features +// imports to boolean literals. Only used for shaken variant builds; the +// standard dist keeps the imports live (externalized) so apps can shake. +function deprecatedFeatures(flags = DEFAULT_FLAGS) { + return [ + require.resolve('babel-plugin-debug-macros'), + { + flags: [ + { + source: '@ember/deprecated-features', + flags: { ...flags }, + }, + ], + }, + 'debug-macros:deprecated-features', + ]; +} + +module.exports = deprecatedFeatures; +module.exports.FLAGS = FLAGS; +module.exports.DEFAULT_FLAGS = DEFAULT_FLAGS; +module.exports.resolveFlags = resolveFlags; +module.exports.parseFlagsFromEnv = parseFlagsFromEnv; diff --git a/index.html b/index.html index 77ad682afac..b1187b65d2f 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', '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..57f559914d6 --- /dev/null +++ b/internal-docs/rfcs/deprecation-early-enablement-and-shaking.md @@ -0,0 +1,390 @@ +--- +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); + } +} +``` + +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. + +## Appendix: wave 1 — classic object model deprecations + +The first consumer of the early-enablement workflow is the classic object +model, matching what the modern build variant removes. All entries are +available-stage (`since: { available: '7.3.0' }`, no `enabled`), `until: +'8.0.0'`: + +| id | fires from | replacement | +|---|---|---| +| `deprecate-ember-object-extend` | the public static `.extend()` | native classes (ember-native-class-codemod) | +| `deprecate-ember-object-reopen` | the public statics `.reopen()` / `.reopenClass()` | subclassing / module refactor | +| `deprecate-ember-mixins` | `Mixin.create` | native class composition | +| `deprecate-computed-properties` | the public `computed()` and every `@ember/object/computed` macro (call-time wrappers — a module-eval deprecation would be dropped by `sideEffects`-aware bundlers) | `@tracked` + native getters, `@cached` | +| `deprecate-observers` | `observer()`, public `addObserver`/`removeObserver` | derived state / explicit events | +| `deprecate-ember-array` | `A()` | native arrays / tracked-built-ins | +| `deprecate-object-proxy` | `ObjectProxy` init (once per class) | direct access / native getters | +| `deprecate-array-proxy` | `ArrayProxy` init (once per class) | tracked-built-ins | + +Design notes: + +- **Internal aliases.** Ember's own framework hierarchy is built with these + exact APIs at module eval (`EmberObject` itself is + `CoreObject.extend(Observable)`), and internals reach them through the same + public modules external code uses — import-path separation is impossible + for `extend`/`reopen`/`Mixin.create`. Framework definitions therefore go + through internal non-deprecating entry points (`internalExtend`, + `internalReopen`, `internalReopenClass` in `@ember/object/core`; + `createMixin` in `@ember/object/mixin`; `internalA` in `@ember/array`; the + metal modules for `computed`/observers), following the `metalInject` + precedent. This includes the runtime paths (autoboot's Router re-extend, + `Router.map`'s `reopenClass`, engine initializer registration) so + framework operation is never blamed on the app. +- **No shaking flags.** The classic-class machinery cannot be tree-shaken + in-module while ember's own base classes are built with the internal + aliases; removing it is the modern build variant's module-swap job. These + are plain registry entries. +- **`Evented` and the array mixins have no separate ids**: applying them + externally goes through `.extend()`/`Mixin.create` and rides those ids. +- **Blanket enablement excludes these ids.** Ember's own suite and + internal-test-helpers exercise the classic APIs pervasively, so the + all-available-deprecations CI rows run with these ids in `except` — the + first real use of the `except`-excludes-`enable` semantics. Targeted test + modules opt in per-id instead. This also documents the migration reality + for apps: `enable: true` is unrealistic for classic-object-model codebases; + per-id adoption is the intended path. +- **Observer deprecation aligns with RFC PR #1115**, which proposes + deprecating observers; this implementation gives that RFC its + available-stage mechanism. + +## Unresolved questions + +- **Should `compliance` also cover available-stage ids the app opted into + 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 83ba3b57cab..4e1bafef3db 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "ember-addon" ], "exports": { + "./deprecation-shaking": "./lib/deprecation-shaking/index.js", "./*": { "development": "./dist/dev/packages/*", "production": "./dist/prod/packages/*", @@ -229,6 +230,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 9b1c7e93827..d9d6560fd63 100644 --- a/packages/@ember/-internals/deprecations/index.ts +++ b/packages/@ember/-internals/deprecations/index.ts @@ -1,11 +1,20 @@ 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 } 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 +36,32 @@ 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` shields an id from the version-based removal computation (which +// includes the _OVERRIDE_DEPRECATION_VERSION simulation) — without it, the +// "Deprecations as errors" CI variant would throw for every API whose +// deprecation is intentionally excluded from a run. It does not shield a +// false flag: in a shaken build the implementation is actually gone. +export function deprecation(options: DeprecationOptions, flag?: boolean): DeprecationObject { return { options, - 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 +118,51 @@ 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. + + ## Deprecating APIs ember-source itself uses + + When the deprecated API is also used by ember-source's own framework code + (and internal use cannot be separated by import path), give the internals a + non-deprecating entry point and put the deprecateUntil call only in the + public one. The reference pattern is `inject` (public wrapper in + @ember/service; internals call metal's `injected_property` directly). + Other examples: `internalExtend` (@ember/object/core), the + `reopenInternal`/`reopenClassInternal` statics (statics rather than module + functions so rollup can scope their side effects to the class — imported + helper calls at module scope make the whole module un-tree-shakable, which + tests/node-vitest/tree-shakability.test.js guards), `createMixin` + (@ember/object/mixin), `internalA` (@ember/array). Include a regression + test proving framework operation (module eval, boot, runtime paths) fires + nothing with the deprecation enabled. */ export const DEPRECATIONS = { DEPRECATE_IMPORT_EMBER(importName: string) { @@ -102,22 +176,89 @@ export const DEPRECATIONS = { ).toLowerCase()}-from-ember`, }); }, - DEPRECATE_IMPORT_INJECT: deprecation({ - for: 'ember-source', - id: 'importing-inject-from-ember-service', - since: { - available: '6.2.0', - enabled: '6.3.0', + DEPRECATE_IMPORT_INJECT: deprecation( + { + for: 'ember-source', + id: 'importing-inject-from-ember-service', + since: { + available: '6.2.0', + enabled: '6.3.0', + }, + until: '7.0.0', + url: 'https://deprecations.emberjs.com/id/importing-inject-from-ember-service', + }, + DEPRECATE_IMPORT_INJECT + ), + DEPRECATE_COMPARABLE_MIXIN: deprecation( + { + for: 'ember-source', + id: 'deprecate-comparable-mixin', + since: { available: '7.2.0', enabled: '7.2.0' }, + until: '7.5.0', + url: 'https://deprecations.emberjs.com/id/deprecate-comparable-mixin', }, - until: '7.0.0', - url: 'https://deprecations.emberjs.com/id/importing-inject-from-ember-service', + DEPRECATE_COMPARABLE_MIXIN + ), + // The classic object model deprecations have no @ember/deprecated-features + // flags: their machinery cannot be tree-shaken in-module while ember's own + // base classes are built with the internal aliases (internalExtend, + // createMixin, ...). Removing the machinery is the modern build variant's + // module-swap job. + DEPRECATE_EMBER_OBJECT_EXTEND: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-object-extend', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-object-extend', + }), + DEPRECATE_EMBER_OBJECT_REOPEN: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-object-reopen', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-object-reopen', + }), + DEPRECATE_EMBER_MIXINS: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-mixins', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-mixins', + }), + DEPRECATE_COMPUTED_PROPERTIES: deprecation({ + for: 'ember-source', + id: 'deprecate-computed-properties', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-computed-properties', + }), + DEPRECATE_OBSERVERS: deprecation({ + for: 'ember-source', + id: 'deprecate-observers', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-observers', + }), + DEPRECATE_EMBER_ARRAY: deprecation({ + for: 'ember-source', + id: 'deprecate-ember-array', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-ember-array', + }), + DEPRECATE_OBJECT_PROXY: deprecation({ + for: 'ember-source', + id: 'deprecate-object-proxy', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-object-proxy', }), - DEPRECATE_COMPARABLE_MIXIN: deprecation({ + DEPRECATE_ARRAY_PROXY: 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', + id: 'deprecate-array-proxy', + since: { available: '7.3.0' }, + until: '8.0.0', + url: 'https://deprecations.emberjs.com/id/deprecate-array-proxy', }), }; 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/glimmer/lib/component.ts b/packages/@ember/-internals/glimmer/lib/component.ts index b4f64601824..9ded8878584 100644 --- a/packages/@ember/-internals/glimmer/lib/component.ts +++ b/packages/@ember/-internals/glimmer/lib/component.ts @@ -13,6 +13,7 @@ import { getViewElement, } from '@ember/-internals/views/lib/system/utils'; import CoreView from '@ember/-internals/views/lib/views/core_view'; +import { internalExtend } from '@ember/object/core'; import EventDispatcher from '@ember/-internals/views/lib/system/event_dispatcher'; import { guidFor } from '@ember/-internals/utils/lib/guid'; import { assert } from '@ember/debug'; @@ -802,7 +803,8 @@ interface Component extends CoreView, TargetActionSupport, ActionSupport, ComponentMethods {} class Component - extends CoreView.extend( + extends internalExtend( + CoreView, TargetActionSupport, ActionSupport, { @@ -1688,8 +1690,9 @@ class Component } } -// We continue to use reopenClass here so that positionalParams can be overridden with reopenClass in subclasses. -Component.reopenClass({ +// We continue to use the reopenClass mechanism here so that positionalParams +// can be overridden with reopenClass in subclasses. +Component.reopenClassInternal({ positionalParams: [], }); 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 e70909d244d..d2093836a61 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/-proxy.ts @@ -3,7 +3,7 @@ */ import { meta } from '@ember/-internals/meta/lib/meta'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { get } from '@ember/-internals/metal/lib/property_get'; import { set } from '@ember/-internals/metal/lib/property_set'; import { defineProperty } from '@ember/-internals/metal/lib/properties'; @@ -90,7 +90,7 @@ interface ProxyMixin { setUnknownProperty(key: string, value: V): V; } -const ProxyMixin = /*@__PURE__*/ Mixin.create({ +const ProxyMixin = /*@__PURE__*/ createMixin({ /** The object whose properties will be forwarded. diff --git a/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts b/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts index bab591600ee..120edeb1354 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/action_handler.ts @@ -2,7 +2,7 @@ @module ember */ -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { get } from '@ember/-internals/metal/lib/property_get'; import { assert } from '@ember/debug'; @@ -21,7 +21,7 @@ interface ActionHandler { actions?: Record unknown>; send(actionName: string, ...args: unknown[]): void; } -const ActionHandler = Mixin.create({ +const ActionHandler = createMixin({ mergedProperties: ['actions'], /** diff --git a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts index e0b10e6eada..6cf802fbe5c 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/comparable.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/comparable.ts @@ -1,5 +1,6 @@ -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; +import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; /** @module ember @@ -19,34 +20,36 @@ import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; interface Comparable { compare: ((a: unknown, b: unknown) => -1 | 0 | 1) | null; } -const Comparable = Mixin.create({ - /** - __Required.__ You must implement this method to apply this mixin. - - Override to return the result of the comparison of the two parameters. The - compare method should return: - - - `-1` if `a < b` - - `0` if `a == b` - - `1` if `a > b` - - Default implementation raises an exception. - - @method compare - @param a {Object} the first object to compare - @param b {Object} the second object to compare - @return {Number} the result of the comparison - @private - */ - init() { - this._super(...arguments); - deprecateUntil( - 'The `Comparable` mixin is deprecated. Implement a `compare` method directly on your class instead.', - DEPRECATIONS.DEPRECATE_COMPARABLE_MIXIN - ); - }, - - compare: null, -}); +const Comparable = DEPRECATE_COMPARABLE_MIXIN + ? createMixin({ + /** + __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/-internals/runtime/lib/mixins/container_proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts index 9b9a870a7c9..81bc72df69d 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/container_proxy.ts @@ -3,7 +3,7 @@ import { schedule, join } from '@ember/runloop'; @module ember */ import type Container from '@ember/-internals/container/lib/container'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import type { ContainerProxy } from '@ember/-internals/owner'; // This is defined as a separate interface so that it can be used in the definition of @@ -21,7 +21,7 @@ interface ContainerProxyMixin extends ContainerProxy { /** @internal */ __container__: Container; } -const ContainerProxyMixin = Mixin.create({ +const ContainerProxyMixin = createMixin({ /** The container stores state. diff --git a/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts b/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts index 600b2bf62f2..225af3329cb 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/registry_proxy.ts @@ -7,7 +7,7 @@ import type { RegistryProxy } from '@ember/-internals/owner'; import type { AnyFn } from '@ember/-internals/utility-types'; import { assert } from '@ember/debug'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; /** RegistryProxyMixin is used to provide public access to specific @@ -21,7 +21,7 @@ interface RegistryProxyMixin extends RegistryProxy { /** @internal */ __registry__: Registry; } -const RegistryProxyMixin = Mixin.create({ +const RegistryProxyMixin = createMixin({ __registry__: null, resolveRegistration(fullName: string) { diff --git a/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts b/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts index 534bf7c68b8..a7752090fc1 100644 --- a/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts +++ b/packages/@ember/-internals/runtime/lib/mixins/target_action_support.ts @@ -5,7 +5,7 @@ import { context } from '@ember/-internals/environment/lib/context'; import { get } from '@ember/-internals/metal/lib/property_get'; import computed from '@ember/-internals/metal/lib/computed'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; @@ -31,7 +31,7 @@ interface TargetActionSupport { /** @internal */ _target?: unknown; } -const TargetActionSupport = Mixin.create({ +const TargetActionSupport = createMixin({ target: null, action: null, actionContext: null, diff --git a/packages/@ember/-internals/views/lib/mixins/action_support.ts b/packages/@ember/-internals/views/lib/mixins/action_support.ts index a4c95d28ac3..87bb89471b3 100644 --- a/packages/@ember/-internals/views/lib/mixins/action_support.ts +++ b/packages/@ember/-internals/views/lib/mixins/action_support.ts @@ -2,7 +2,7 @@ @module ember */ import { get } from '@ember/-internals/metal/lib/property_get'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import inspect from '@ember/debug/lib/inspect'; import { assert } from '@ember/debug'; @@ -14,7 +14,7 @@ import { assert } from '@ember/debug'; interface ActionSupport { send(actionName: string, ...args: unknown[]): void; } -const ActionSupport = Mixin.create({ +const ActionSupport = createMixin({ send(actionName: string, ...args: unknown[]) { assert( `Attempted to call .send() with the action '${actionName}' on the destroyed object '${this}'.`, diff --git a/packages/@ember/-internals/views/lib/views/core_view.ts b/packages/@ember/-internals/views/lib/views/core_view.ts index ceb6c0cfa8f..666bfcc80ef 100644 --- a/packages/@ember/-internals/views/lib/views/core_view.ts +++ b/packages/@ember/-internals/views/lib/views/core_view.ts @@ -2,6 +2,7 @@ import type { Renderer, View } from '@ember/-internals/glimmer/lib/renderer'; import inject from '@ember/-internals/metal/lib/injected_property'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import Evented from '@ember/object/evented'; +import { internalExtend } from '@ember/object/core'; import { FrameworkObject } from '@ember/object/-internals'; import type { ViewState } from './states'; import states from './states'; @@ -24,7 +25,7 @@ import states from './states'; */ interface CoreView extends Evented, ActionHandler, View {} -class CoreView extends FrameworkObject.extend(Evented, ActionHandler) { +class CoreView extends internalExtend(FrameworkObject, Evented, ActionHandler) { isView = true; declare _states: typeof states; diff --git a/packages/@ember/application/index.ts b/packages/@ember/application/index.ts index e4ac76f8de3..bab27ecc214 100644 --- a/packages/@ember/application/index.ts +++ b/packages/@ember/application/index.ts @@ -15,6 +15,7 @@ import RSVP from '@ember/-internals/runtime/lib/ext/rsvp'; import EventDispatcher from '@ember/-internals/views/lib/system/event_dispatcher'; import Route from '@ember/routing/route'; import Router from '@ember/routing/router'; +import { internalExtend } from '@ember/object/core'; import HashLocation from '@ember/routing/hash-location'; import HistoryLocation from '@ember/routing/history-location'; import NoneLocation from '@ember/routing/none-location'; @@ -386,7 +387,7 @@ class Application extends Engine { // Create subclass of Router for this Application instance. // This is to ensure that someone reopening `App.Router` does not // tamper with the default `Router`. - this.Router = (this.Router || Router).extend() as typeof Router; + this.Router = internalExtend(this.Router || Router) as typeof Router; this._buildDeprecatedInstance(); this.waitForDOMReady(); diff --git a/packages/@ember/array/index.ts b/packages/@ember/array/index.ts index 37d0eb3c8e0..2975ef57952 100644 --- a/packages/@ember/array/index.ts +++ b/packages/@ember/array/index.ts @@ -10,8 +10,9 @@ import { } from '@ember/-internals/metal/lib/property_events'; import { get } from '@ember/-internals/metal/lib/property_get'; import { set } from '@ember/-internals/metal/lib/property_set'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import Enumerable from '@ember/enumerable'; import MutableEnumerable from '@ember/enumerable/mutable'; import compare from '@ember/utils/lib/compare'; @@ -1139,7 +1140,7 @@ interface EmberArray extends Enumerable { */ without(value: T): NativeArray; } -const EmberArray = Mixin.create(Enumerable, { +const EmberArray = createMixin(Enumerable, { init() { this._super(...arguments); setEmberArray(this); @@ -1687,7 +1688,7 @@ interface MutableArray extends EmberArray, MutableEnumerable { */ addObjects(objects: T[]): this; } -const MutableArray = Mixin.create(EmberArray, MutableEnumerable, { +const MutableArray = createMixin(EmberArray, MutableEnumerable, { clear() { let len = this.length; if (len === 0) { @@ -2023,7 +2024,7 @@ interface MutableArrayWithoutNative extends Omit< */ interface NativeArray extends Array, Observable, MutableArrayWithoutNative {} -let NativeArray = Mixin.create(MutableArray, Observable, { +let NativeArray = createMixin(MutableArray, Observable, { objectAt(idx: number) { return this[idx]; }, @@ -2049,14 +2050,13 @@ NativeArray.keys().forEach((methodName) => { NativeArray = NativeArray.without(...ignore); -let A: (arr?: Array) => NativeArray; - -A = function (this: unknown, arr?: Array) { - assert( - 'You cannot create an Ember Array with `new A()`, please update to calling A as a function: `A()`', - !(this instanceof A) - ); +/** + Non-deprecating equivalent of `A()` for ember-source's own internals. + External code must use `A()`. + @internal +*/ +export function internalA(arr?: Array): NativeArray { if (isEmberArray(arr)) { // SAFETY: If it's a true native array and it is also an EmberArray then it should be an Ember NativeArray return arr as unknown as NativeArray; @@ -2064,6 +2064,21 @@ A = function (this: unknown, arr?: Array) { // SAFETY: This will return an NativeArray but TS can't infer that. return NativeArray.apply(arr ?? []) as NativeArray; } +} + +let A: (arr?: Array) => NativeArray; + +A = function (this: unknown, arr?: Array) { + assert( + 'You cannot create an Ember Array with `new A()`, please update to calling A as a function: `A()`', + !(this instanceof A) + ); + deprecateUntil( + 'Ember Arrays are deprecated. Use native arrays (replacing them on change), or tracked collections from tracked-built-ins.', + DEPRECATIONS.DEPRECATE_EMBER_ARRAY + ); + + return internalA(arr); }; export { A, NativeArray, MutableArray }; diff --git a/packages/@ember/array/proxy.ts b/packages/@ember/array/proxy.ts index d4a9907c2ff..19906dcbea3 100644 --- a/packages/@ember/array/proxy.ts +++ b/packages/@ember/array/proxy.ts @@ -15,6 +15,7 @@ import { get } from '@ember/-internals/metal/lib/property_get'; import type { PropertyDidChange } from '@ember/-internals/metal/lib/property_events'; import { isObject } from '@ember/-internals/utils/lib/spec'; import EmberObject from '@ember/object'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import EmberArray, { type NativeArray } from '@ember/array'; import MutableArray from '@ember/array/mutable'; import { assert } from '@ember/debug'; @@ -114,6 +115,10 @@ function customTagForArrayProxy(proxy: object, key: string) { @uses MutableArray @public */ +// Dedupe: the deprecation fires once per ArrayProxy subclass, not once per +// instance — proxies can be created in volume. +const deprecatedClasses = new WeakSet(); + interface ArrayProxy extends MutableArray { /** The content array. Must be an object that implements `Array` and/or @@ -201,6 +206,17 @@ class ArrayProxy extends EmberObject implements PropertyDidChange { init(props: object | undefined) { super.init(props); + // The isEnabled gate keeps the dedupe set empty while the deprecation is + // disabled, so enabling it later (e.g. per test module) still warns for + // classes instantiated before that point. + if (DEPRECATIONS.DEPRECATE_ARRAY_PROXY.isEnabled && !deprecatedClasses.has(this.constructor)) { + deprecatedClasses.add(this.constructor); + deprecateUntil( + 'ArrayProxy is deprecated. Use a native array, or a tracked collection from tracked-built-ins, and expose derived state with native getters.', + DEPRECATIONS.DEPRECATE_ARRAY_PROXY + ); + } + setCustomTagFor(this, customTagForArrayProxy); } @@ -405,7 +421,7 @@ class ArrayProxy extends EmberObject implements PropertyDidChange { } } -ArrayProxy.reopen(MutableArray, { +ArrayProxy.reopenInternal(MutableArray, { arrangedContent: alias('content'), }); diff --git a/packages/@ember/array/tests/a-deprecation-test.js b/packages/@ember/array/tests/a-deprecation-test.js new file mode 100644 index 00000000000..6d902316ae2 --- /dev/null +++ b/packages/@ember/array/tests/a-deprecation-test.js @@ -0,0 +1,45 @@ +import { A, internalA } from '@ember/array'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { moduleForDevelopment, testUnless, AbstractTestCase } from 'internal-test-helpers'; + +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + +moduleForDevelopment( + 'Ember Array deprecation', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test A() is silent by default'](assert) { + expectNoDeprecation(() => { + A(['a', 'b']); + }); + assert.ok(true, 'no deprecations fired'); + } + + [`${testUnless(REMOVAL_SIMULATED)} A() fires when enabled and still works`](assert) { + setDeprecationStagesConfig({ enable: ['deprecate-ember-array'] }); + + let arr; + expectDeprecation(() => { + arr = A(['a', 'b']); + }, /Ember Arrays are deprecated/); + + assert.strictEqual(arr.objectAt(1), 'b', 'the array works'); + } + + ['@test internalA never fires'](assert) { + setDeprecationStagesConfig({ enable: ['deprecate-ember-array'] }); + + expectNoDeprecation(() => { + internalA(['a', 'b']); + }); + assert.ok(true, 'no deprecations fired'); + } + } +); diff --git a/packages/@ember/controller/index.ts b/packages/@ember/controller/index.ts index 0d86064d773..695ddd5ec93 100644 --- a/packages/@ember/controller/index.ts +++ b/packages/@ember/controller/index.ts @@ -2,12 +2,13 @@ import { getOwner } from '@ember/-internals/owner'; // This is imported from -in import computed from '@ember/-internals/metal/lib/computed'; import { get } from '@ember/-internals/metal/lib/property_get'; import { FrameworkObject } from '@ember/object/-internals'; +import { internalExtend } from '@ember/object/core'; import metalInject from '@ember/-internals/metal/lib/injected_property'; import type { DecoratorPropertyDescriptor, ElementDescriptor, } from '@ember/-internals/metal/lib/decorator'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import type { RouteArgs } from '@ember/routing/-internals'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import type { Transition } from 'router_js'; @@ -233,7 +234,7 @@ interface ControllerMixin extends ActionHandler { */ replaceRoute(...args: RouteArgs): Transition; } -const ControllerMixin = Mixin.create(ActionHandler, { +const ControllerMixin = createMixin(ActionHandler, { /* ducktype as a controller */ isController: true, @@ -315,7 +316,7 @@ const ControllerMixin = Mixin.create(ActionHandler, { @public */ interface Controller<_T = unknown> extends FrameworkObject, ControllerMixin<_T> {} -class Controller<_T = unknown> extends FrameworkObject.extend(ControllerMixin) {} +class Controller<_T = unknown> extends internalExtend(FrameworkObject, ControllerMixin) {} /** Creates a property that lazily looks up another controller in the container. diff --git a/packages/@ember/debug/data-adapter.ts b/packages/@ember/debug/data-adapter.ts index 4b37faaff4b..dc0ac757de2 100644 --- a/packages/@ember/debug/data-adapter.ts +++ b/packages/@ember/debug/data-adapter.ts @@ -6,7 +6,7 @@ import { dasherize } from '@ember/-internals/string'; import Namespace from '@ember/application/namespace'; import type { NativeArray } from '@ember/array'; import EmberObject from '@ember/object'; -import { A as emberA } from '@ember/array'; +import { internalA as emberA } from '@ember/array'; import type { Cache } from '@glimmer/validator/lib/tracking'; import { consumeTag, createCache, getValue, untrack } from '@glimmer/validator/lib/tracking'; import { tagFor } from '@glimmer/validator/lib/meta'; diff --git a/packages/@ember/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..8ca0dc3109d 100644 --- a/packages/@ember/deprecated-features/index.ts +++ b/packages/@ember/deprecated-features/index.ts @@ -1,5 +1,16 @@ -// These versions should be the version that the deprecation was _introduced_, -// not the version that the feature will be removed. +// One flag per shakable deprecation, named identically to its entry in the +// DEPRECATIONS registry (@ember/-internals/deprecations). All flags are true +// in the standard build; a shaken build sets flags to false, which both +// strips the guarded legacy code paths and makes the deprecation report +// itself as removed at runtime (so unguarded reaches throw). +// +// Guard convention: reference the imported const directly (no destructuring, +// renaming, or property access — babel-plugin-debug-macros can only fold +// direct references), keep the deprecation call inside the guarded branch, +// and put the post-removal behavior in the other branch. -/** Introduced in 4.0.0-beta.1 */ -export const ASSIGN = true; +/** id: deprecate-comparable-mixin, since: 7.2.0/7.2.0, until: 7.5.0 */ +export const DEPRECATE_COMPARABLE_MIXIN = true; + +/** id: importing-inject-from-ember-service, since: 6.2.0/6.3.0, until: 7.0.0 */ +export const DEPRECATE_IMPORT_INJECT = true; diff --git a/packages/@ember/engine/index.ts b/packages/@ember/engine/index.ts index 478de9e9b13..a626b75ddea 100644 --- a/packages/@ember/engine/index.ts +++ b/packages/@ember/engine/index.ts @@ -3,6 +3,7 @@ export { getEngineParent, setEngineParent } from './parent'; import { canInvoke } from '@ember/-internals/utils/lib/invoke'; import Controller from '@ember/controller'; import Namespace from '@ember/application/namespace'; +import { internalExtend } from '@ember/object/core'; import Registry from '@ember/-internals/container/lib/registry'; import type { ResolverClass } from '@ember/-internals/container/lib/registry'; import DAG from 'dag-map'; @@ -56,7 +57,7 @@ export interface Initializer { */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Engine extends RegistryProxyMixin {} -class Engine extends Namespace.extend(RegistryProxyMixin) { +class Engine extends internalExtend(Namespace, RegistryProxyMixin) { static initializers: Record> = Object.create(null); static instanceInitializers: Record> = Object.create(null); @@ -494,7 +495,7 @@ export function buildInitializerMethod< let attrs = { [bucketName]: Object.create(this[bucketName]), }; - this.reopenClass(attrs); + this.reopenClassInternal(attrs); } assert( diff --git a/packages/@ember/engine/instance.ts b/packages/@ember/engine/instance.ts index 050d27eb3b8..9fedb50dd70 100644 --- a/packages/@ember/engine/instance.ts +++ b/packages/@ember/engine/instance.ts @@ -3,6 +3,7 @@ */ import EmberObject from '@ember/object'; +import { internalExtend } from '@ember/object/core'; import RSVP from '@ember/-internals/runtime/lib/ext/rsvp'; import { assert } from '@ember/debug'; import { default as Registry, privatize as P } from '@ember/-internals/container/lib/registry'; @@ -53,7 +54,7 @@ export interface EngineInstanceOptions { // type checking, we have broken part of our public API contract. Medium-term, // the goal here is to `EngineInstance` simple be `Owner`. interface EngineInstance extends RegistryProxyMixin, ContainerProxyMixin, InternalOwner, Owner {} -class EngineInstance extends EmberObject.extend(RegistryProxyMixin, ContainerProxyMixin) { +class EngineInstance extends internalExtend(EmberObject, RegistryProxyMixin, ContainerProxyMixin) { /** @private @method setupRegistry diff --git a/packages/@ember/enumerable/index.ts b/packages/@ember/enumerable/index.ts index 2929953363b..84b5da22bf6 100644 --- a/packages/@ember/enumerable/index.ts +++ b/packages/@ember/enumerable/index.ts @@ -1,4 +1,4 @@ -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; /** @module @ember/enumerable @@ -15,6 +15,6 @@ import Mixin from '@ember/object/mixin'; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Enumerable {} -const Enumerable = Mixin.create(); +const Enumerable = createMixin(); export default Enumerable; diff --git a/packages/@ember/enumerable/mutable.ts b/packages/@ember/enumerable/mutable.ts index 5f1b01182b9..fe97c6e3d05 100644 --- a/packages/@ember/enumerable/mutable.ts +++ b/packages/@ember/enumerable/mutable.ts @@ -1,5 +1,5 @@ import Enumerable from '@ember/enumerable'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; /** @module ember @@ -17,6 +17,6 @@ import Mixin from '@ember/object/mixin'; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface MutableEnumerable extends Enumerable {} -const MutableEnumerable = Mixin.create(Enumerable); +const MutableEnumerable = createMixin(Enumerable); export default MutableEnumerable; diff --git a/packages/@ember/object/computed.ts b/packages/@ember/object/computed.ts index 5e4d41bc33b..862da118a43 100644 --- a/packages/@ember/object/computed.ts +++ b/packages/@ember/object/computed.ts @@ -1,40 +1,94 @@ +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; +import type { AnyFn } from '@ember/-internals/utility-types'; +import metalExpandProperties from '@ember/-internals/metal/lib/expand_properties'; +import metalAlias from '@ember/-internals/metal/lib/alias'; +import { + empty as _empty, + notEmpty as _notEmpty, + none as _none, + not as _not, + bool as _bool, + match as _match, + equal as _equal, + gt as _gt, + gte as _gte, + lt as _lt, + lte as _lte, + oneWay as _oneWay, + readOnly as _readOnly, + deprecatingAlias as _deprecatingAlias, + and as _and, + or as _or, +} from './lib/computed/computed_macros'; +import { + sum as _sum, + min as _min, + max as _max, + map as _map, + sort as _sort, + setDiff as _setDiff, + mapBy as _mapBy, + filter as _filter, + filterBy as _filterBy, + uniq as _uniq, + uniqBy as _uniqBy, + union as _union, + intersect as _intersect, + collect as _collect, +} from './lib/computed/reduce_computed_macros'; + +// Call-time deprecation wrappers, one per macro, rather than a single +// module-eval deprecation: consumer bundlers treat this module as +// side-effect-free (see `sideEffects` in package.json) and are allowed to +// drop top-level statements when only re-exports are used. ember-source's +// own internals import the underlying lib modules directly and stay silent. +function deprecatedMacro( + macro: F, + message = 'Computed property macros are deprecated. Replace them with `@tracked` properties and native getters (with `@cached` where memoization is needed).' +): F { + return function (this: unknown, ...args: unknown[]) { + deprecateUntil(message, DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES); + return macro.apply(this, args); + } as F; +} + export { ComputedProperty as default } from '@ember/-internals/metal/lib/computed'; -export { default as expandProperties } from '@ember/-internals/metal/lib/expand_properties'; -export { default as alias } from '@ember/-internals/metal/lib/alias'; -export { - empty, - notEmpty, - none, - not, - bool, - match, - equal, - gt, - gte, - lt, - lte, - oneWay, - oneWay as reads, - readOnly, - deprecatingAlias, - and, - or, -} from './lib/computed/computed_macros'; +export const expandProperties = deprecatedMacro( + metalExpandProperties, + '`expandProperties` is deprecated along with the computed property system it supports.' +); +export const alias = deprecatedMacro(metalAlias); -export { - sum, - min, - max, - map, - sort, - setDiff, - mapBy, - filter, - filterBy, - uniq, - uniqBy, - union, - intersect, - collect, -} from './lib/computed/reduce_computed_macros'; +export const empty = deprecatedMacro(_empty); +export const notEmpty = deprecatedMacro(_notEmpty); +export const none = deprecatedMacro(_none); +export const not = deprecatedMacro(_not); +export const bool = deprecatedMacro(_bool); +export const match = deprecatedMacro(_match); +export const equal = deprecatedMacro(_equal); +export const gt = deprecatedMacro(_gt); +export const gte = deprecatedMacro(_gte); +export const lt = deprecatedMacro(_lt); +export const lte = deprecatedMacro(_lte); +export const oneWay = deprecatedMacro(_oneWay); +export { oneWay as reads }; +export const readOnly = deprecatedMacro(_readOnly); +export const deprecatingAlias = deprecatedMacro(_deprecatingAlias); +export const and = deprecatedMacro(_and); +export const or = deprecatedMacro(_or); + +export const sum = deprecatedMacro(_sum); +export const min = deprecatedMacro(_min); +export const max = deprecatedMacro(_max); +export const map = deprecatedMacro(_map); +export const sort = deprecatedMacro(_sort); +export const setDiff = deprecatedMacro(_setDiff); +export const mapBy = deprecatedMacro(_mapBy); +export const filter = deprecatedMacro(_filter); +export const filterBy = deprecatedMacro(_filterBy); +export const uniq = deprecatedMacro(_uniq); +export const uniqBy = deprecatedMacro(_uniqBy); +export const union = deprecatedMacro(_union); +export const intersect = deprecatedMacro(_intersect); +export const collect = deprecatedMacro(_collect); diff --git a/packages/@ember/object/core.ts b/packages/@ember/object/core.ts index 858015174fb..59d1bfa1b61 100644 --- a/packages/@ember/object/core.ts +++ b/packages/@ember/object/core.ts @@ -14,7 +14,8 @@ import { activateObserver } from '@ember/-internals/metal/lib/observer'; import { defineProperty } from '@ember/-internals/metal/lib/properties'; import { descriptorForProperty, isClassicDecorator } from '@ember/-internals/metal/lib/decorator'; import { DEBUG_INJECTION_FUNCTIONS } from '@ember/-internals/metal/lib/injected_property'; -import Mixin, { applyMixin } from '@ember/object/mixin'; +import Mixin, { applyMixin, createMixin } from '@ember/object/mixin'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import makeArray from '@ember/array/make'; import { assert } from '@ember/debug'; @@ -711,10 +712,12 @@ class CoreObject { this: Statics & EmberClassConstructor, ...mixins: M ): Readonly & EmberClassConstructor & MergeArray; - static extend(...mixins: any[]) { - let Class = class extends this {}; - reopen.apply(Class.PrototypeMixin, mixins); - return Class; + static extend(this: typeof CoreObject, ...mixins: any[]) { + deprecateUntil( + 'The classic class definition API `.extend()` is deprecated. Convert to a native class, for example with the ember-native-class-codemod.', + DEPRECATIONS.DEPRECATE_EMBER_OBJECT_EXTEND + ); + return internalExtend(this, ...mixins); } /** @@ -837,9 +840,23 @@ class CoreObject { @public */ static reopen(this: C, ...args: any[]): C { - this.willReopen(); - reopen.apply(this.PrototypeMixin, args); - return this; + deprecateUntil( + 'The classic class API `.reopen()` is deprecated. Define the properties and methods on a native class (or a subclass) instead.', + DEPRECATIONS.DEPRECATE_EMBER_OBJECT_REOPEN + ); + return internalReopen(this, ...args); + } + + /** + Non-deprecating equivalent of `reopen` for ember-source's own framework + definitions. A static (rather than the module-level `internalReopen`) so + rollup can scope the call's side effect to the class and keep the calling + module tree-shakable. + + @internal + */ + static reopenInternal(this: C, ...args: any[]): C { + return internalReopen(this, ...args); } static willReopen() { @@ -851,7 +868,7 @@ class CoreObject { // make sure that it gets properly applied. Reusing the same mixin after // the first `proto` call will cause it to get skipped. if (prototypeMixinMap.has(this)) { - prototypeMixinMap.set(this, Mixin.create(this.PrototypeMixin)); + prototypeMixinMap.set(this, createMixin(this.PrototypeMixin)); } } } @@ -921,8 +938,26 @@ class CoreObject { this: C, ...mixins: Array> ): C { - applyMixin(this, mixins); - return this; + deprecateUntil( + 'The classic class API `.reopenClass()` is deprecated. Define static properties and methods on a native class (or a subclass) instead.', + DEPRECATIONS.DEPRECATE_EMBER_OBJECT_REOPEN + ); + return internalReopenClass(this, ...mixins); + } + + /** + Non-deprecating equivalent of `reopenClass` for ember-source's own + framework definitions. A static (rather than the module-level + `internalReopenClass`) so rollup can scope the call's side effect to the + class and keep the calling module tree-shakable. + + @internal + */ + static reopenClassInternal( + this: C, + ...mixins: Array> + ): C { + return internalReopenClass(this, ...mixins); } static detect(obj: unknown) { @@ -1010,7 +1045,7 @@ class CoreObject { static get PrototypeMixin() { let prototypeMixin = prototypeMixinMap.get(this); if (prototypeMixin === undefined) { - prototypeMixin = Mixin.create(); + prototypeMixin = createMixin(); prototypeMixin.ownerConstructor = this; prototypeMixinMap.set(this, prototypeMixin); } @@ -1129,4 +1164,48 @@ if (DEBUG) { }; } +/** + Non-deprecating equivalent of `CoreObject.extend` for ember-source's own + framework class definitions. External code must use the public static. + + @internal +*/ +export function internalExtend>( + Base: Statics & EmberClassConstructor, + ...mixins: M +): Readonly & EmberClassConstructor & MergeArray; +export function internalExtend(Base: any, ...mixins: any[]) { + let Class = class extends Base {}; + reopen.apply((Class as unknown as typeof CoreObject).PrototypeMixin, mixins); + return Class; +} + +/** + Non-deprecating equivalent of the static `reopen` for ember-source's own + framework definitions. Unlike bare `Class.PrototypeMixin.reopen(...)`, this + keeps the `willReopen` cache invalidation, so it is safe after first + instantiation. + + @internal +*/ +export function internalReopen(Class: C, ...args: any[]): C { + Class.willReopen(); + reopen.apply(Class.PrototypeMixin, args); + return Class; +} + +/** + Non-deprecating equivalent of the static `reopenClass` for ember-source's + own framework definitions. + + @internal +*/ +export function internalReopenClass( + Class: C, + ...mixins: Array> +): C { + applyMixin(Class, mixins); + return Class; +} + export default CoreObject; diff --git a/packages/@ember/object/evented.ts b/packages/@ember/object/evented.ts index 097d274d4bf..cf6fc40d767 100644 --- a/packages/@ember/object/evented.ts +++ b/packages/@ember/object/evented.ts @@ -4,7 +4,7 @@ import { hasListeners, sendEvent, } from '@ember/-internals/metal/lib/events'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; export { on } from '@ember/-internals/metal/lib/events'; @@ -149,7 +149,7 @@ interface Evented { */ has(name: string): boolean; } -const Evented = Mixin.create({ +const Evented = createMixin({ on(name: string, target: object, method?: string | Function) { addListener(this, name, target, method); return this; diff --git a/packages/@ember/object/index.ts b/packages/@ember/object/index.ts index 1bf0587949a..fce3032f82e 100644 --- a/packages/@ember/object/index.ts +++ b/packages/@ember/object/index.ts @@ -9,8 +9,10 @@ import expandProperties from '@ember/-internals/metal/lib/expand_properties'; import { getFactoryFor } from '@ember/-internals/container/lib/container'; import { setObservers } from '@ember/-internals/utils/lib/super'; import type { AnyFn } from '@ember/-internals/utility-types'; -import CoreObject from '@ember/object/core'; +import CoreObject, { internalExtend } from '@ember/object/core'; import Observable from '@ember/object/observable'; +import metalComputed from '@ember/-internals/metal/lib/computed'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; export { notifyPropertyChange } from '@ember/-internals/metal/lib/property_events'; export { defineProperty } from '@ember/-internals/metal/lib/properties'; @@ -18,7 +20,16 @@ export { get } from '@ember/-internals/metal/lib/property_get'; export { set, trySet } from '@ember/-internals/metal/lib/property_set'; export { default as getProperties } from '@ember/-internals/metal/lib/get_properties'; export { default as setProperties } from '@ember/-internals/metal/lib/set_properties'; -export { default as computed } from '@ember/-internals/metal/lib/computed'; + +// Deprecating wrapper: ember-source's own uses go through the metal module +// directly and stay silent. +export const computed = ((...args: Parameters) => { + deprecateUntil( + 'Computed properties are deprecated. Replace `computed()` with `@tracked` properties and native getters (with `@cached` where memoization is needed).', + DEPRECATIONS.DEPRECATE_COMPUTED_PROPERTIES + ); + return metalComputed(...args); +}) as typeof metalComputed; /** @module @ember/object @@ -36,7 +47,7 @@ export { default as computed } from '@ember/-internals/metal/lib/computed'; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface EmberObject extends Observable {} -class EmberObject extends CoreObject.extend(Observable) { +class EmberObject extends internalExtend(CoreObject, Observable) { get _debugContainerKey() { let factory = getFactoryFor(this); return factory !== undefined && factory.fullName; @@ -269,6 +280,11 @@ export function observer( | [propertyName: string, ...additionalPropertyNames: string[], func: T] | [ObserverDefinition] ): T { + deprecateUntil( + 'Observers are deprecated. Derive state with `@tracked` properties and native getters, or react to changes explicitly.', + DEPRECATIONS.DEPRECATE_OBSERVERS + ); + let funcOrDef = args.pop(); assert( diff --git a/packages/@ember/object/lib/computed/reduce_computed_macros.ts b/packages/@ember/object/lib/computed/reduce_computed_macros.ts index a08f1953b3d..fb73e98fc8c 100644 --- a/packages/@ember/object/lib/computed/reduce_computed_macros.ts +++ b/packages/@ember/object/lib/computed/reduce_computed_macros.ts @@ -8,7 +8,7 @@ import { isElementDescriptor } from '@ember/-internals/metal/lib/decorator'; import computed from '@ember/-internals/metal/lib/computed'; import { get } from '@ember/-internals/metal/lib/property_get'; import compare from '@ember/utils/lib/compare'; -import EmberArray, { A as emberA, uniqBy as uniqByArray } from '@ember/array'; +import EmberArray, { internalA as emberA, uniqBy as uniqByArray } from '@ember/array'; import type { NativeArray } from '@ember/array'; function isNativeOrEmberArray(obj: unknown): obj is unknown[] | EmberArray { diff --git a/packages/@ember/object/mixin.ts b/packages/@ember/object/mixin.ts index c76b57f7dbe..ce3bd601ea0 100644 --- a/packages/@ember/object/mixin.ts +++ b/packages/@ember/object/mixin.ts @@ -6,6 +6,7 @@ import type { Meta } from '@ember/-internals/meta/lib/meta'; import { meta as metaFor, peekMeta } from '@ember/-internals/meta/lib/meta'; import { observerListenerMetaFor, ROOT, wrap } from '@ember/-internals/utils/lib/super'; import { assert } from '@ember/debug'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; import { DEBUG } from '@glimmer/env'; import type { ComputedDecorator, @@ -577,6 +578,10 @@ export default class Mixin { @public */ static create(...args: any[]): InstanceType { + deprecateUntil( + 'Ember Mixins are deprecated. Replace mixin usage with native class composition (utility functions, delegation, or base classes).', + DEPRECATIONS.DEPRECATE_EMBER_MIXINS + ); setUnprocessedMixins(); let M = this; return new M(args, undefined) as InstanceType; @@ -687,6 +692,20 @@ export default class Mixin { } } +/** + Non-deprecating equivalent of `Mixin.create` for ember-source's own + framework mixins. External code must use `Mixin.create`. + + Constructs `Mixin` directly rather than the static's `this`-based `new M()` + — ember-source never subclasses Mixin internally. + + @internal +*/ +export function createMixin(...args: any[]): Mixin { + setUnprocessedMixins(); + return new Mixin(args, undefined); +} + if (DEBUG) { Object.defineProperty(Mixin, '_disableDebugSeal', { configurable: true, diff --git a/packages/@ember/object/observable.ts b/packages/@ember/object/observable.ts index b35c3dcd11f..b1438c05175 100644 --- a/packages/@ember/object/observable.ts +++ b/packages/@ember/object/observable.ts @@ -15,7 +15,7 @@ import { set } from '@ember/-internals/metal/lib/property_set'; import getProperties from '@ember/-internals/metal/lib/get_properties'; import setProperties from '@ember/-internals/metal/lib/set_properties'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import { assert } from '@ember/debug'; export type ObserverMethod = @@ -420,7 +420,7 @@ interface Observable { */ cacheFor(key: K): unknown; } -const Observable = Mixin.create({ +const Observable = createMixin({ get(keyName: string) { return get(this, keyName); }, diff --git a/packages/@ember/object/observers.ts b/packages/@ember/object/observers.ts index 595cec5fd1a..0dfe9771396 100644 --- a/packages/@ember/object/observers.ts +++ b/packages/@ember/object/observers.ts @@ -1 +1,24 @@ -export { addObserver, removeObserver } from '@ember/-internals/metal/lib/observer'; +import { + addObserver as metalAddObserver, + removeObserver as metalRemoveObserver, +} from '@ember/-internals/metal/lib/observer'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; + +// Deprecating wrappers: ember-source's own uses go through the metal module +// directly and stay silent. + +export const addObserver = ((...args: Parameters) => { + deprecateUntil( + 'Observers are deprecated. Derive state with `@tracked` properties and native getters, or react to changes explicitly.', + DEPRECATIONS.DEPRECATE_OBSERVERS + ); + return metalAddObserver(...args); +}) as typeof metalAddObserver; + +export const removeObserver = ((...args: Parameters) => { + deprecateUntil( + 'Observers are deprecated. Derive state with `@tracked` properties and native getters, or react to changes explicitly.', + DEPRECATIONS.DEPRECATE_OBSERVERS + ); + return metalRemoveObserver(...args); +}) as typeof metalRemoveObserver; diff --git a/packages/@ember/object/promise-proxy-mixin.ts b/packages/@ember/object/promise-proxy-mixin.ts index 9571771a803..e6acde45487 100644 --- a/packages/@ember/object/promise-proxy-mixin.ts +++ b/packages/@ember/object/promise-proxy-mixin.ts @@ -1,7 +1,7 @@ import { get } from '@ember/-internals/metal/lib/property_get'; import setProperties from '@ember/-internals/metal/lib/set_properties'; import computed from '@ember/-internals/metal/lib/computed'; -import Mixin from '@ember/object/mixin'; +import { createMixin } from '@ember/object/mixin'; import type { AnyFn, MethodNamesOf } from '@ember/-internals/utility-types'; import type RSVP from 'rsvp'; import type CoreObject from '@ember/object/core'; @@ -211,7 +211,7 @@ interface PromiseProxyMixin { */ finally: this['promise']['finally']; } -const PromiseProxyMixin = Mixin.create({ +const PromiseProxyMixin = createMixin({ reason: null, isPending: computed('isSettled', function () { diff --git a/packages/@ember/object/proxy.ts b/packages/@ember/object/proxy.ts index d561d7777df..a74889c16b8 100644 --- a/packages/@ember/object/proxy.ts +++ b/packages/@ember/object/proxy.ts @@ -4,6 +4,7 @@ import { FrameworkObject } from '@ember/object/-internals'; import _ProxyMixin from '@ember/-internals/runtime/lib/mixins/-proxy'; +import { deprecateUntil, DEPRECATIONS } from '@ember/-internals/deprecations'; /** `ObjectProxy` forwards all properties not defined by the proxy itself @@ -119,8 +120,27 @@ interface ObjectProxy extends _ProxyMixin { setProperties>(hash: T): T; } +// Dedupe: the deprecation fires once per ObjectProxy subclass, not once per +// instance — proxies can be created in volume. +const deprecatedClasses = new WeakSet(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars -class ObjectProxy extends FrameworkObject {} +class ObjectProxy extends FrameworkObject { + init(properties: object | undefined) { + super.init(properties); + + // The isEnabled gate keeps the dedupe set empty while the deprecation is + // disabled, so enabling it later (e.g. per test module) still warns for + // classes instantiated before that point. + if (DEPRECATIONS.DEPRECATE_OBJECT_PROXY.isEnabled && !deprecatedClasses.has(this.constructor)) { + deprecatedClasses.add(this.constructor); + deprecateUntil( + 'ObjectProxy is deprecated. Access the underlying object directly, or expose derived state with native getters.', + DEPRECATIONS.DEPRECATE_OBJECT_PROXY + ); + } + } +} ObjectProxy.PrototypeMixin.reopen(_ProxyMixin); export default ObjectProxy; diff --git a/packages/@ember/object/tests/classic-object-model-deprecations-test.js b/packages/@ember/object/tests/classic-object-model-deprecations-test.js new file mode 100644 index 00000000000..6204643aa94 --- /dev/null +++ b/packages/@ember/object/tests/classic-object-model-deprecations-test.js @@ -0,0 +1,147 @@ +import EmberObject from '@ember/object'; +import { internalExtend, internalReopen, internalReopenClass } from '@ember/object/core'; +import Mixin, { createMixin } from '@ember/object/mixin'; +import Application from '@ember/application'; +import EmberRouter from '@ember/routing/router'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { + moduleForDevelopment, + AbstractTestCase, + ModuleBasedTestResolver, + runTask, + testUnless, +} from 'internal-test-helpers'; + +const CLASSIC_IDS = [ + 'deprecate-ember-object-extend', + 'deprecate-ember-object-reopen', + 'deprecate-ember-mixins', +]; + +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped — the standard pattern for tests +// of removed deprecations. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + +moduleForDevelopment( + 'classic object model deprecations', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test the deprecations are available-stage: nothing fires by default'](assert) { + expectNoDeprecation(() => { + let Klass = EmberObject.extend({ someProp: 'value' }); + Klass.reopen({ otherProp: 'value' }); + Klass.reopenClass({ staticProp: 'value' }); + Mixin.create({ mixedIn: 'value' }); + }); + assert.ok(true, 'no deprecations fired'); + } + + [`${testUnless(REMOVAL_SIMULATED)} extend fires when enabled`]() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectDeprecation(() => { + EmberObject.extend({ someProp: 'value' }); + }, /The classic class definition API `\.extend\(\)` is deprecated/); + } + + [`${testUnless(REMOVAL_SIMULATED)} extend fires for subclasses created with extend`]() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + let Klass; + expectDeprecation(() => { + Klass = EmberObject.extend(); + }, /`\.extend\(\)` is deprecated/); + + expectDeprecation(() => { + Klass.extend(); + }, /`\.extend\(\)` is deprecated/); + } + + [`${testUnless(REMOVAL_SIMULATED)} reopen and reopenClass fire when enabled`]() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + class Klass extends EmberObject {} + + expectDeprecation(() => { + Klass.reopen({ someProp: 'value' }); + }, /The classic class API `\.reopen\(\)` is deprecated/); + + expectDeprecation(() => { + Klass.reopenClass({ staticProp: 'value' }); + }, /The classic class API `\.reopenClass\(\)` is deprecated/); + } + + [`${testUnless(REMOVAL_SIMULATED)} Mixin.create fires when enabled`]() { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectDeprecation(() => { + Mixin.create({ mixedIn: 'value' }); + }, /Ember Mixins are deprecated/); + } + + ['@test the internal aliases never fire'](assert) { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectNoDeprecation(() => { + let Klass = internalExtend(EmberObject, { someProp: 'value' }); + internalReopen(Klass, { otherProp: 'value' }); + internalReopenClass(Klass, { staticProp: 'value' }); + createMixin({ mixedIn: 'value' }); + // instantiation applies the pending mixins (proto/applyMixin) and + // must not fire either + Klass.create().destroy(); + }); + assert.ok(true, 'no deprecations fired'); + } + } +); + +moduleForDevelopment( + 'classic object model deprecations: framework runtime paths', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + if (this.app) { + runTask(() => this.app.destroy()); + } + } + + // The regression net for ember's internal runtime uses of the classic + // definition APIs: booting an app (autoboot re-extends the Router + // internally), Router.map (reopenClass on the app's router), and + // initializer registration (reopenClass on the app class) must not be + // blamed on the app. + ['@test autoboot, Router.map, and initializers fire nothing when enabled'](assert) { + setDeprecationStagesConfig({ enable: CLASSIC_IDS }); + + expectNoDeprecation(() => { + class TestRouter extends EmberRouter {} + TestRouter.map(function () { + this.route('example'); + }); + + class TestApplication extends Application {} + TestApplication.initializer({ + name: 'test-initializer', + initialize() {}, + }); + + this.app = runTask(() => + TestApplication.create({ + rootElement: '#qunit-fixture', + autoboot: true, + router: null, + Resolver: ModuleBasedTestResolver, + }) + ); + }); + assert.ok(true, 'no deprecations fired'); + } + } +); diff --git a/packages/@ember/object/tests/computed-observers-deprecations-test.js b/packages/@ember/object/tests/computed-observers-deprecations-test.js new file mode 100644 index 00000000000..0bfb088f990 --- /dev/null +++ b/packages/@ember/object/tests/computed-observers-deprecations-test.js @@ -0,0 +1,140 @@ +import EmberObject, { computed, observer } from '@ember/object'; +import { readOnly } from '@ember/object/computed'; +import { addObserver, removeObserver } from '@ember/object/observers'; +import { readOnly as metalReadOnly } from '@ember/object/lib/computed/computed_macros'; +import metalComputed from '@ember/-internals/metal/lib/computed'; +import { + addObserver as metalAddObserver, + removeObserver as metalRemoveObserver, +} from '@ember/-internals/metal/lib/observer'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { + moduleForDevelopment, + testUnless, + AbstractTestCase, + runLoopSettled, +} from 'internal-test-helpers'; + +const IDS = ['deprecate-computed-properties', 'deprecate-observers']; + +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + +moduleForDevelopment( + 'computed property and observer deprecations', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test nothing fires by default'](assert) { + expectNoDeprecation(() => { + let obj = EmberObject.create({ first: 'a' }); + let handler = () => {}; + computed('first', function () { + return this.first; + }); + readOnly('first'); + observer('first', function () {}); + addObserver(obj, 'first', null, handler, true); + removeObserver(obj, 'first', null, handler, true); + obj.destroy(); + }); + assert.ok(true, 'no deprecations fired'); + } + + [`${testUnless(REMOVAL_SIMULATED)} computed() fires when enabled and still works`](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let cp; + expectDeprecation(() => { + cp = computed('first', function () { + return `${this.first}!`; + }); + }, /Computed properties are deprecated/); + + // extend stays silent here: deprecate-ember-object-extend is not in + // the enabled set for this module + let Klass = EmberObject.extend({ first: 'a', shouted: cp }); + let obj = Klass.create(); + assert.strictEqual(obj.get('shouted'), 'a!', 'the computed property works'); + obj.destroy(); + } + + [`${testUnless(REMOVAL_SIMULATED)} computed macros fire per call and still work`](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let cp; + expectDeprecation(() => { + cp = readOnly('first'); + }, /Computed property macros are deprecated/); + + // no dedupe: a second call fires again + expectDeprecation(() => { + readOnly('second'); + }, /Computed property macros are deprecated/); + + let Klass = EmberObject.extend({ first: 'a', firstAlias: cp }); + let obj = Klass.create(); + assert.strictEqual(obj.get('firstAlias'), 'a', 'the macro works'); + obj.destroy(); + } + + ['@test the deep macro modules never fire'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + expectNoDeprecation(() => { + metalReadOnly('first'); + }); + assert.ok(true, 'no deprecations fired'); + } + + [`${testUnless(REMOVAL_SIMULATED)} observer() fires when enabled`]() { + setDeprecationStagesConfig({ enable: IDS }); + + expectDeprecation(() => { + observer('first', function () {}); + }, /Observers are deprecated/); + } + + [`${testUnless(REMOVAL_SIMULATED)} addObserver and removeObserver fire when enabled`](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let obj = EmberObject.create({ first: 'a' }); + let handler = () => {}; + + expectDeprecation(() => { + addObserver(obj, 'first', null, handler, true); + }, /Observers are deprecated/); + + expectDeprecation(() => { + removeObserver(obj, 'first', null, handler, true); + }, /Observers are deprecated/); + + obj.destroy(); + assert.ok(true, 'ran without throwing'); + } + + async ['@test the internal metal paths never fire'](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + let obj = EmberObject.create({ first: 'a' }); + let handler = () => {}; + + expectNoDeprecation(() => { + metalComputed('first', function () { + return this.first; + }); + metalAddObserver(obj, 'first', null, handler, true); + metalRemoveObserver(obj, 'first', null, handler, true); + }); + + obj.destroy(); + await runLoopSettled(); + assert.ok(true, 'no deprecations fired'); + } + } +); diff --git a/packages/@ember/object/tests/proxy-deprecations-test.js b/packages/@ember/object/tests/proxy-deprecations-test.js new file mode 100644 index 00000000000..7ca0cbfb840 --- /dev/null +++ b/packages/@ember/object/tests/proxy-deprecations-test.js @@ -0,0 +1,73 @@ +import ObjectProxy from '@ember/object/proxy'; +import ArrayProxy from '@ember/array/proxy'; +import { setDeprecationStagesConfig } from '@ember/debug'; +import { emberVersionGte } from '@ember/-internals/deprecations'; +import { moduleForDevelopment, testUnless, AbstractTestCase } from 'internal-test-helpers'; + +const IDS = ['deprecate-object-proxy', 'deprecate-array-proxy']; + +// Under _OVERRIDE_DEPRECATION_VERSION removal simulation these APIs throw +// instead of warning (the test config replaces the harness's except list), +// so the warn-expecting tests are skipped. +const REMOVAL_SIMULATED = emberVersionGte('8.0.0'); + +moduleForDevelopment( + 'ObjectProxy and ArrayProxy deprecations', + class extends AbstractTestCase { + teardown() { + setDeprecationStagesConfig(null); + } + + ['@test proxies are silent by default'](assert) { + expectNoDeprecation(() => { + ObjectProxy.create({ content: { name: 'foo' } }).destroy(); + ArrayProxy.create({ content: ['a'] }).destroy(); + }); + assert.ok(true, 'no deprecations fired'); + } + + [`${testUnless(REMOVAL_SIMULATED)} ObjectProxy fires once per class when enabled`](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + class ProxyA extends ObjectProxy {} + class ProxyB extends ObjectProxy {} + + let first; + expectDeprecation(() => { + first = ProxyA.create({ content: { name: 'foo' } }); + }, /ObjectProxy is deprecated/); + + let second; + expectNoDeprecation(() => { + second = ProxyA.create({ content: { name: 'bar' } }); + }); + + let third; + expectDeprecation(() => { + third = ProxyB.create({ content: { name: 'baz' } }); + }, /ObjectProxy is deprecated/); + + assert.strictEqual(first.get('name'), 'foo', 'the proxy works'); + [first, second, third].forEach((proxy) => proxy.destroy()); + } + + [`${testUnless(REMOVAL_SIMULATED)} ArrayProxy fires once per class when enabled`](assert) { + setDeprecationStagesConfig({ enable: IDS }); + + class ProxyA extends ArrayProxy {} + + let first; + expectDeprecation(() => { + first = ProxyA.create({ content: ['a', 'b'] }); + }, /ArrayProxy is deprecated/); + + let second; + expectNoDeprecation(() => { + second = ProxyA.create({ content: ['c'] }); + }); + + assert.strictEqual(first.objectAt(1), 'b', 'the proxy works'); + [first, second].forEach((proxy) => proxy.destroy()); + } + } +); diff --git a/packages/@ember/routing/lib/routing-service.ts b/packages/@ember/routing/lib/routing-service.ts index 610fcdac4e9..5e606169cf6 100644 --- a/packages/@ember/routing/lib/routing-service.ts +++ b/packages/@ember/routing/lib/routing-service.ts @@ -4,7 +4,7 @@ import { getOwner } from '@ember/-internals/owner'; import { assert } from '@ember/debug'; -import { readOnly } from '@ember/object/computed'; +import { readOnly } from '@ember/object/lib/computed/computed_macros'; import Service from '@ember/service'; import type { ModelFor } from 'router_js'; import type Route from '@ember/routing/route'; @@ -128,7 +128,7 @@ export default class RoutingService extends Service { } } -RoutingService.reopen({ +RoutingService.reopenInternal({ targetState: readOnly('router.targetState'), currentState: readOnly('router.currentState'), currentRouteName: readOnly('router.currentRouteName'), diff --git a/packages/@ember/routing/none-location.ts b/packages/@ember/routing/none-location.ts index e396d8ef335..a64bccf2c53 100644 --- a/packages/@ember/routing/none-location.ts +++ b/packages/@ember/routing/none-location.ts @@ -127,7 +127,7 @@ export default class NoneLocation extends EmberObject implements EmberLocation { } } -NoneLocation.reopen({ +NoneLocation.reopenInternal({ path: '', rootURL: '/', }); diff --git a/packages/@ember/routing/route.ts b/packages/@ember/routing/route.ts index 4e05176c34e..8b09cfa23be 100644 --- a/packages/@ember/routing/route.ts +++ b/packages/@ember/routing/route.ts @@ -11,8 +11,9 @@ import { set } from '@ember/-internals/metal/lib/property_set'; import getProperties from '@ember/-internals/metal/lib/get_properties'; import setProperties from '@ember/-internals/metal/lib/set_properties'; import EmberObject from '@ember/object'; +import { internalExtend } from '@ember/object/core'; import Evented from '@ember/object/evented'; -import { A as emberA } from '@ember/array'; +import { internalA as emberA } from '@ember/array'; import ActionHandler from '@ember/-internals/runtime/lib/mixins/action_handler'; import typeOf from '@ember/utils/lib/type-of'; import { isProxy } from '@ember/-internals/utils/lib/is_proxy'; @@ -256,7 +257,10 @@ interface Route extends IRoute, ActionHandler, Evented { error?(error: Error, transition: Transition): boolean | void; } -class Route extends EmberObject.extend(ActionHandler, Evented) implements IRoute { +class Route + extends internalExtend(EmberObject, ActionHandler, Evented) + implements IRoute +{ static isRouteFactory = true; // These properties will end up appearing in the public interface because we @@ -2039,7 +2043,7 @@ export function hasDefaultSerialize(route: Route): boolean { } // Set these here so they can be overridden with extend -Route.reopen({ +Route.reopenInternal({ mergedProperties: ['queryParams'], queryParams: {}, templateName: null, diff --git a/packages/@ember/routing/router-service.ts b/packages/@ember/routing/router-service.ts index 9fc5e3b4df1..cd0f6161d30 100644 --- a/packages/@ember/routing/router-service.ts +++ b/packages/@ember/routing/router-service.ts @@ -3,8 +3,9 @@ */ import { getOwner } from '@ember/-internals/owner'; import Evented from '@ember/object/evented'; +import { internalExtend } from '@ember/object/core'; import { assert } from '@ember/debug'; -import { readOnly } from '@ember/object/computed'; +import { readOnly } from '@ember/object/lib/computed/computed_macros'; import Service from '@ember/service'; import { consumeTag } from '@glimmer/validator/lib/tracking'; import { tagFor } from '@glimmer/validator/lib/meta'; @@ -62,7 +63,7 @@ interface RouterService extends Evented { callback: (transition: Transition) => void ): this; } -class RouterService extends Service.extend(Evented) { +class RouterService extends internalExtend(Service, Evented) { [ROUTER]?: EmberRouter; get _router(): EmberRouter { diff --git a/packages/@ember/routing/router.ts b/packages/@ember/routing/router.ts index 7f6d760dd9d..dd0393d2862 100644 --- a/packages/@ember/routing/router.ts +++ b/packages/@ember/routing/router.ts @@ -27,7 +27,8 @@ import type { } from '@ember/routing/location'; import type RouterService from '@ember/routing/router-service'; import EmberObject from '@ember/object'; -import { A as emberA } from '@ember/array'; +import { internalExtend } from '@ember/object/core'; +import { internalA as emberA } from '@ember/array'; import typeOf from '@ember/utils/lib/type-of'; import Evented from '@ember/object/evented'; import { assert, info } from '@ember/debug'; @@ -142,7 +143,7 @@ const { slice } = Array.prototype; @uses Evented @public */ -class EmberRouter extends EmberObject.extend(Evented) implements Evented { +class EmberRouter extends internalExtend(EmberObject, Evented) implements Evented { /** Represents the URL of the root of the application, often '/'. This prefix is assumed on all routes defined on this router. @@ -254,7 +255,7 @@ class EmberRouter extends EmberObject.extend(Evented) implements Evented { if (!this.dslCallbacks) { this.dslCallbacks = []; // FIXME: Can we remove this? - this.reopenClass({ dslCallbacks: this.dslCallbacks }); + this.reopenClassInternal({ dslCallbacks: this.dslCallbacks }); } this.dslCallbacks.push(callback); @@ -1814,7 +1815,7 @@ function forEachQueryParam( } } -EmberRouter.reopen({ +EmberRouter.reopenInternal({ didTransition: defaultDidTransition, willTransition: defaultWillTransition, rootURL: '/', 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/packages/ember-testing/lib/adapters/adapter.ts b/packages/ember-testing/lib/adapters/adapter.ts index 26876da9d42..761461fc31b 100644 --- a/packages/ember-testing/lib/adapters/adapter.ts +++ b/packages/ember-testing/lib/adapters/adapter.ts @@ -1,4 +1,5 @@ import EmberObject from '@ember/object'; +import { internalExtend } from '@ember/object/core'; /** @module @ember/test @@ -16,7 +17,7 @@ interface Adapter extends EmberObject { asyncEnd(): void; exception(error: unknown): never; } -const Adapter = EmberObject.extend({ +const Adapter = internalExtend(EmberObject, { /** This callback will be called whenever an async operation is about to start. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25ffa279b3e..3aca0eb7c91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,9 @@ importers: '@ember/debug': specifier: workspace:* version: link:../debug + '@ember/deprecated-features': + specifier: workspace:* + version: link:../deprecated-features '@ember/destroyable': specifier: workspace:* version: link:../destroyable @@ -646,6 +649,9 @@ importers: '@ember/utils': specifier: workspace:* version: link:../utils + '@ember/version': + specifier: workspace:* + version: link:../version '@glimmer/destroyable': specifier: workspace:* version: link:../../@glimmer/destroyable @@ -1084,6 +1090,9 @@ importers: '@ember/debug': specifier: workspace:* version: link:../debug + '@ember/deprecated-features': + specifier: workspace:* + version: link:../deprecated-features '@ember/object': specifier: workspace:* version: link:../object diff --git a/rollup.config.mjs b/rollup.config.mjs index 37333ab65f9..1b7f8ce39ad 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,5 +1,5 @@ import { dirname, parse, resolve, join } from 'node:path'; -import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { globSync } from 'glob'; @@ -14,6 +14,7 @@ const projectRoot = dirname(fileURLToPath(import.meta.url)); const packageCache = PackageCache.shared('ember-source', projectRoot); const buildDebugMacroPlugin = require('./broccoli/build-debug-macro-plugin.cjs'); const canaryFeatures = require('./broccoli/canary-features.cjs'); +const deprecatedFeatures = require('./broccoli/deprecated-features.cjs'); const testDependencies = [ 'qunit', @@ -32,6 +33,19 @@ let configs = [ glimmerSyntaxCJS(), ]; +// A deprecation-shaken variant: EMBER_DEPRECATION_FLAGS="DEPRECATE_X=false,..." +// (or "all=false") builds dist/deprecation-custom/{dev,prod} with the flagged +// deprecations compile-time folded and their guarded code paths eliminated. +// CI uses this to prove each shakable deprecation actually leaves the bundle; +// apps normally shake instead via the ember-source/deprecation-shaking plugin. +if (process.env.EMBER_DEPRECATION_FLAGS) { + let flags = deprecatedFeatures.parseFlagsFromEnv(process.env.EMBER_DEPRECATION_FLAGS); + configs.push( + sharedESMConfig({ input: esmInputs(), debugMacrosMode: true, deprecationFlags: flags }), + sharedESMConfig({ input: esmInputs(), debugMacrosMode: false, deprecationFlags: flags }) + ); +} + if (process.env.DEBUG_SINGLE_CONFIG) { configs = configs.slice( parseInt(process.env.DEBUG_SINGLE_CONFIG), @@ -71,8 +85,9 @@ function esmInputs() { }; } -function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false }) { - let outputDir = debugMacrosMode === false ? 'dist/prod' : 'dist/dev'; +function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false, deprecationFlags }) { + let distRoot = deprecationFlags ? 'dist/deprecation-custom' : 'dist'; + let outputDir = debugMacrosMode === false ? `${distRoot}/prod` : `${distRoot}/dev`; let babelConfig = { ...sharedBabelConfig }; babelConfig.plugins = [ ...babelConfig.plugins, @@ -80,6 +95,12 @@ function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false }) canaryFeatures(), ]; + if (deprecationFlags) { + // Shaken variant: fold the flags to literals so guarded deprecated code + // paths are dead-code-eliminated by rollup's treeshake. + babelConfig.plugins.push(deprecatedFeatures(deprecationFlags)); + } + let plugins = [ babel({ babelHelpers: 'bundled', @@ -89,12 +110,20 @@ function sharedESMConfig({ input, debugMacrosMode, includePackageMeta = false }) }), resolveTS(), version(), - resolvePackages({ ...exposedDependencies(), ...hiddenDependencies() }), + deprecationFlagsModule(deprecationFlags), + resolvePackages( + { ...exposedDependencies(), ...hiddenDependencies() }, + // The standard dist keeps @ember/deprecated-features live (externalized + // to a package self-reference) so apps can shake per-deprecation. In + // the shaken variant the imports are already folded away by babel. + { externalizeDeprecatedFeatures: !deprecationFlags } + ), pruneEmptyBundles(), ]; if (includePackageMeta) { plugins.push(packageMeta()); + plugins.push(emitDeprecationFlagsMeta()); } return { @@ -401,11 +430,12 @@ function resolveTS() { export function resolvePackages(deps, params) { const isExternal = params?.isExternal; const enableLocalDebug = params?.enableLocalDebug ?? false; + const externalizeDeprecatedFeatures = params?.externalizeDeprecatedFeatures ?? false; return { enforce: 'pre', name: 'resolve-packages', - async resolveId(source) { + async resolveId(source, importer) { if (source.startsWith('\0')) { return; } @@ -419,6 +449,16 @@ export function resolvePackages(deps, params) { return resolve(projectRoot, 'packages/@glimmer/local-debug-flags/disabled.ts'); } + // Keep the deprecation flags live in the published dist: consumers of + // the flags import a single shared module (a package self-reference + // that resolves through our own `exports` map), which the + // ember-source/deprecation-shaking app plugin can replace to shake + // deprecated code. Only imports are redirected; the module itself + // (importer === undefined) still builds as a normal entrypoint. + if (externalizeDeprecatedFeatures && importer && source === '@ember/deprecated-features') { + return { external: true, id: 'ember-source/@ember/deprecated-features/index.js' }; + } + let pkgName = packageName(source); if (pkgName) { // having a pkgName means this is not a relative import @@ -507,6 +547,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/app-template/config/environment.js b/smoke-tests/app-template/config/environment.js index f5e5c491ff3..78be3f7fff4 100644 --- a/smoke-tests/app-template/config/environment.js +++ b/smoke-tests/app-template/config/environment.js @@ -7,10 +7,15 @@ module.exports = function (environment) { rootURL: '/', locationType: 'history', EmberENV: { - /* The following enables the infrastructure allow us to test as if deprecations + /* The following enables the infrastructure allow us to test as if deprecations have been turned into errors at a specific version. */ - _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + // Deprecation ids to shield from the removal simulation (and blanket + // enablement) — comma-separated, mirroring the main test harness. + DEPRECATION_STAGES: process.env.EXCEPT_DEPRECATIONS + ? { except: process.env.EXCEPT_DEPRECATIONS.split(',') } + : undefined, EXTEND_PROTOTYPES: false, FEATURES: { // Here you can enable experimental features on an ember canary build diff --git a/smoke-tests/scenarios/deprecation-shaking-test.ts b/smoke-tests/scenarios/deprecation-shaking-test.ts new file mode 100644 index 00000000000..5353c677261 --- /dev/null +++ b/smoke-tests/scenarios/deprecation-shaking-test.ts @@ -0,0 +1,127 @@ +import { Project, Scenarios } from 'scenario-tester'; +import type { PreparedApp } from 'scenario-tester'; +import { dirname, join } from 'node:path'; +import { readdirSync, readFileSync } from 'node:fs'; +import * as QUnit from 'qunit'; +const { module: Qmodule, test } = QUnit; + +// A runtime string inside the shaken branch of the Comparable mixin — present +// in a normal build, gone from a shaken one. +const MARKER = 'The `Comparable` mixin is deprecated'; + +function distContains(appDir: string, text: string): boolean { + let queue = [join(appDir, 'dist')]; + while (queue.length > 0) { + let dir = queue.pop()!; + for (let entry of readdirSync(dir, { withFileTypes: true })) { + let full = join(dir, entry.name); + if (entry.isDirectory()) { + queue.push(full); + } else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(text)) { + return true; + } + } + } + return false; +} + +Scenarios.fromProject(() => + Project.fromDir(dirname(require.resolve('../v2-app-template/package.json')), { + linkDevDeps: true, + }) +) + .map('deprecation-shaking', (project) => { + project.mergeFiles({ + // The plugin is applied only when SHAKE=1 so the same app can build + // both ways. + 'vite.config.mjs': ` + import { defineConfig } from 'vite'; + import { extensions, classicEmberSupport, ember } from '@embroider/vite'; + import { babel } from '@rollup/plugin-babel'; + import { deprecationShaking } from 'ember-source/deprecation-shaking'; + + export default defineConfig({ + // the app template has no terser; rollup's DCE is what shaking + // relies on, so minification is irrelevant here + build: { minify: false }, + plugins: [ + classicEmberSupport(), + ember(), + ...(process.env.SHAKE + ? [ + deprecationShaking({ + strip: ['deprecate-comparable-mixin', 'importing-inject-from-ember-service'], + }), + ] + : []), + babel({ + babelHelpers: 'runtime', + extensions, + }), + ], + }); + `, + tests: { + integration: { + 'deprecation-shaking-test.js': ` + import { module, test } from 'qunit'; + import Comparable from '@ember/-internals/runtime/lib/mixins/comparable'; + import { DEPRECATIONS } from '@ember/-internals/deprecations'; + import { DEPRECATE_COMPARABLE_MIXIN } from '@ember/deprecated-features'; + import { inject } from '@ember/service'; + + module('deprecation shaking', function () { + // Passes in both the shaken and unshaken build: the runtime + // must agree with the flag either way. + test('flag state is consistent with runtime behavior', function (assert) { + if (DEPRECATE_COMPARABLE_MIXIN) { + assert.true(Boolean(Comparable), 'Comparable mixin exists while the flag is on'); + // 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('an API past its until version throws the removal error', function (assert) { + assert.throws(() => inject('foo'), /was removed in ember-source/); + }); + }); + `, + }, + }, + }); + }) + .forEachScenario((scenario) => { + Qmodule(scenario.name, function (hooks) { + let app: PreparedApp; + hooks.before(async () => { + app = await scenario.prepare(); + }); + + // Control: without the plugin the deprecated code ships (also proves + // the MARKER stays a valid probe). + test('unshaken build contains the deprecated code and tests pass', async function (assert) { + let result = await app.execute('pnpm test'); + assert.equal(result.exitCode, 0, result.output); + assert.true(distContains(app.dir, MARKER), 'marker present in unshaken build'); + }); + + test('shaken build drops the deprecated code and tests pass', async function (assert) { + let result = await app.execute('pnpm test', { env: { SHAKE: '1' } }); + assert.equal(result.exitCode, 0, result.output); + assert.false(distContains(app.dir, MARKER), 'marker absent from shaken build'); + }); + + test('shaken production build also drops the deprecated code', async function (assert) { + let result = await app.execute('pnpm build', { env: { SHAKE: '1' } }); + assert.equal(result.exitCode, 0, result.output); + assert.false(distContains(app.dir, MARKER), 'marker absent from shaken prod build'); + }); + }); + }); diff --git a/smoke-tests/v2-app-template/config/environment.js b/smoke-tests/v2-app-template/config/environment.js index 9c2e006a05b..54408a4c2f9 100644 --- a/smoke-tests/v2-app-template/config/environment.js +++ b/smoke-tests/v2-app-template/config/environment.js @@ -7,10 +7,15 @@ module.exports = function (environment) { rootURL: '/', locationType: 'history', EmberENV: { - /* The following enables the infrastructure allow us to test as if deprecations + /* The following enables the infrastructure allow us to test as if deprecations have been turned into errors at a specific version. */ - _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + _OVERRIDE_DEPRECATION_VERSION: process.env.OVERRIDE_DEPRECATION_VERSION, + // Deprecation ids to shield from the removal simulation (and blanket + // enablement) — comma-separated, mirroring the main test harness. + DEPRECATION_STAGES: process.env.EXCEPT_DEPRECATIONS + ? { except: process.env.EXCEPT_DEPRECATIONS.split(',') } + : undefined, EXTEND_PROTOTYPES: false, FEATURES: { // Here you can enable experimental features on an ember canary build 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 db1994ff30a..f7730093cc6 100644 --- a/tests/docs/expected.cjs +++ b/tests/docs/expected.cjs @@ -1,6 +1,7 @@ module.exports = { classitems: [ 'A', + 'DEPRECATION_STAGES', 'EXTEND_PROTOTYPES', 'GUID_KEY', 'GUID_PREFIX', 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`); + } + }); +});