diff --git a/text/1216-deprecate-ember-component.md b/text/1216-deprecate-ember-component.md new file mode 100644 index 0000000000..3baf733357 --- /dev/null +++ b/text/1216-deprecate-ember-component.md @@ -0,0 +1,496 @@ +--- +stage: accepted +start-date: 2026-07-27T00:00:00.000Z +release-date: +release-versions: +teams: + - framework + - learning + - typescript +prs: + accepted: https://github.com/emberjs/rfcs/pull/1216 +project-link: +--- + + + +# Deprecate Classic Ember Component aka `import Component from '@ember/component'` + +## Summary + +Deprecate the classic component class -- the default export of `@ember/component`. + +Glimmer components ([`@glimmer/component`](https://api.emberjs.com/ember/release/modules/@glimmer%2Fcomponent)) have been the default component in Ember projects since Octane (`ember-source@3.15`, 2019), and every feature of the classic component has a replacement that is smaller, easier to teach, and doesn't depend on the classic object model. + +## Motivation +Classic Components have been replaced by Glimmer Components. The replacement was introduced nearly a decade ago and made default in 2019. We've known since we began designing Glimmer Components that the Classic Components had serious limitations and were difficult to teach. + +Additionally, Classic components are the largest remaining consumer of everything else we've been deprecating or working to deprecate: + +- they extend `EmberObject`, requiring the [Classic Class system](https://github.com/emberjs/rfcs/pull/1117) +- they are assembled out of [Mixins](https://github.com/emberjs/rfcs/pull/1116) (`Evented`, `ActionSupport`, `TargetActionSupport`, ...) +- they are the last thing in the framework that supports two-way bindings +- their `click()` / `mouseEnter()` / etc methods require the app-wide `EventDispatcher`, which attaches delegated listeners to the root of every Ember app whether or not anything uses them + +**We cannot finish removing the classic object model while the classic component still exists.** + +Deleting the classic component (at the major following this deprecation) lets us also delete the `EventDispatcher`, `Ember.View`-era internals, and the component half of the two-way binding system -- some of the oldest and most expensive code in `ember-source`. + +Removing Classic components can drastically reduce the maintenance and teaching burden of the Framework. + +## Transition Path + +### What is deprecated + +Only the _default_ export of `@ember/component`. The module itself and its named exports are unaffected: + +| | export | status | +| - | ------ | ------ | +| 🌐 | `default` (the classic `Component` class) | **deprecated** | +| 🌐 | `setComponentTemplate` | stays | +| 🌐 | `getComponentTemplate` | stays | +| 🌐 | `setComponentManager` | stays | +| 🌐 | `capabilities` | stays | +| 🌐 | `@ember/component/template-only` | stays | + +Since importing a module can't warn at runtime, the deprecation fires when the class is extended (via native `class extends` or `.extend()`) or instantiated: + +```js +deprecate(message, false, { + id: 'ember-component', + until: '8.0.0', + for: 'ember-source', + url: 'https://deprecations.emberjs.com/id/ember-component', + since: { available: '7.x', enabled: '7.x' }, // exact versions dependent on implementation timing +}); +``` + + +### Deprecation Guide + +> [!NOTE] +> gjs / gts is supported back to 3.28, and includes `@ember/component`. This allows very zebra-striped incremental migrations, if needed. + +
You only have a JS file + +```js +// before: app/components/greeting.js +import Component from '@ember/component'; + +export default Component.extend(); +``` + +```hbs +{{! before: app/components/greeting.hbs }} +

Hello, {{@name}}!

+``` + +Delete the class. A template is a component: + +```gjs +// after: app/components/greeting.gjs + +``` + +
+ +
Local state and actions + +```js +// before +import Component from '@ember/component'; +import { action } from '@ember/object'; + +export default class Toggle extends Component { + isOn = false; + + @action + flip() { + this.set('isOn', !this.isOn); + } +} +``` + +```gjs +// after +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; + +export default class Toggle extends Component { + @tracked isOn = false; + + flip = () => (this.isOn = !this.isOn); + + +} +``` + +`this.set` becomes plain assignment to a `@tracked` property. + +
+ +
The element: tagName, classNames, attributeBindings, elementId, ariaRole + +Glimmer components have no wrapper element. Write the element in the template, and move each JS setting onto it as an attribute: + +```js +// before +import Component from '@ember/component'; + +export default class UserCard extends Component { + tagName = 'section'; + classNames = ['user-card']; + classNameBindings = ['isSelected:selected']; + attributeBindings = ['label:aria-label']; + ariaRole = 'listitem'; +} +``` + +```hbs +{{! before }} + +{{yield}} +``` + +```gjs +// after + +``` + +`...attributes` receives the attributes passed at the call site (`class`, `id`, `data-*`, ...); place it on the element that should receive them. + +
+ +
DOM event methods: click(), mouseEnter(), keyDown(), ... + +```js +// before +import Component from '@ember/component'; + +export default class Item extends Component { + click(event) { + this.select(event); + } +} +``` + +```gjs +// after +import { on } from '@ember/modifier'; + + +``` + +Each event method maps to `{{on}}` with the DOM event name: `click()` β†’ `{{on "click" ...}}`, `keyDown()` β†’ `{{on "keydown" ...}}`, `mouseEnter()` β†’ `{{on "mouseenter" ...}}`. + +
+ +
Lifecycle hooks and this.element + +Anything that used `didInsertElement` / `didUpdateAttrs` / `willDestroyElement` to touch the DOM becomes a modifier, attached to the element it manages. Teardown is the modifier's return value. + +```js +// before +import Component from '@ember/component'; + +export default class Chart extends Component { + didInsertElement() { + super.didInsertElement(...arguments); + this.chart = renderChart(this.element, this.data); + } + + didUpdateAttrs() { + super.didUpdateAttrs(...arguments); + this.chart.update(this.data); + } + + willDestroyElement() { + this.chart.destroy(); + super.willDestroyElement(...arguments); + } +} +``` + +```gjs +// after +import { modifier } from 'ember-modifier'; + +const drawChart = modifier((element, [data]) => { + let chart = renderChart(element, data); + + return () => chart.destroy(); +}); + + +``` + +For non-DOM teardown, `@glimmer/component` still has `willDestroy`, and `registerDestructor` from `@ember/destroyable` works everywhere. + +
+ +
didReceiveAttrs / computed properties deriving state + +Derived data becomes a getter: + +```js +// before +import Component from '@ember/component'; +import { computed } from '@ember/object'; + +export default class FullName extends Component { + @computed('first', 'last') + get fullName() { + return `${this.first} ${this.last}`; + } +} +``` + +```js +// after +import Component from '@glimmer/component'; + +export default class FullName extends Component { + get fullName() { + return `${this.args.first} ${this.args.last}`; + } +} +``` + +If the getter is expensive, `@cached` (from `@glimmer/tracking`) memoizes it. If the getter only formats arguments (like this one), skip the class and put the expression in a template-only component. + +
+ +
Two-way bindings + +Classic components let a child `this.set()` an argument and have the write propagate into the parent. Glimmer components' `this.args` is read-only: the owner of the state changes it, and the child asks via a callback. + +```js +// before: the child writes the parent's property +import Component from '@ember/component'; +import { action } from '@ember/object'; + +export default class Counter extends Component { + @action + increment() { + this.set('count', this.count + 1); + } +} +``` + +```gjs +// after: the parent owns the state, the child receives a function +// parent +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import Counter from './counter'; + +export default class Parent extends Component { + @tracked count = 0; + + increment = () => this.count++; + + +} +``` + +
+ +
positionalParams + +Angle bracket invocation has no positional arguments. Give the arguments names: + +```hbs +{{! before }} +{{avatar user size}} +``` + +```hbs +{{! after }} + +``` + +
+ +#### Migrating incrementally + +You do not need to migrate everything all at once. Classic and Glimmer components coexist in the same app, the same route, even the same template -- migrate one component at a time, in any order. + +Here is the whole path in one picture. Diamonds ask "does your code do this?" -- a "no" means there is nothing to do on that branch. Rectangles are hand-work, double-walled boxes are codemods that do the work for you. When several branches leave the same node, they are independent: do them in any order, or in parallel across a team. The only arrows that mean "must happen first" are the ones you see: + +```mermaid +flowchart TD + Start(["import Component from '@ember/component'"]) + AppWide(["once, app-wide: run whichever apply"]) + Hub(["then, for each component, leaves first"]) + + Start --> AppWide + + AppWide --> Colocated{"templates in app/templates/components/,
or layout / layoutName?"} + AppWide --> Curlies{"invoked curly-style:
{{my-component}}?"} + AppWide --> Implicit{"bare {{foo}} in templates instead of
{{this.foo}} / {{@foo}}?"} + AppWide --> ClassicClass{"still using .extend()?"} + + Colocated -->|yes| Migrator[["ember-component-template-colocation-migrator"]] + Curlies -->|yes| Angle[["ember-angle-brackets-codemod"]] + Implicit -->|yes| NoImplicit[["ember-no-implicit-this-codemod"]] + ClassicClass -->|"yes, with Mixins"| Mixins["deal with Mixins first: inline them, or
convert to class decorators (RFC #1116)"] + Mixins --> Native[["ember-native-class-codemod"]] + ClassicClass -->|yes| Native + + Migrator --> Hub + Angle --> Hub + NoImplicit --> Hub + Native --> Hub + + Hub --> Element{"anything on the wrapper element?
tagName / classNames / classNameBindings /
attributeBindings / elementId / ariaRole,
click()-style methods, this.element"} + Hub --> TwoWay{"this.set() on passed-in properties,
or callers reaching for {{mut}}?"} + Hub --> Evented{"this.trigger() / this.on()
from Evented?"} + Hub --> Observers{"observers?"} + Hub --> Actions{"actions hash /
this.send()?"} + Hub --> Positional{"positionalParams?"} + + Element -->|yes| Flatten["write the element in the template,
move the bindings onto it, add ...attributes,
set tagName = ''"] + Flatten --> Events{"click() / keyDown() /
mouseEnter() / ...?"} + Events -->|yes| On["{{on}} on the element,
in the template"] + Flatten --> Hooks{"didInsertElement / didUpdateAttrs /
willDestroyElement / this.element?"} + Hooks -->|yes| Mod["extract a modifier
(ember-modifier)"] + + TwoWay -->|yes| Ddau["the caller keeps the state and
passes a callback down"] + Evented -->|yes| Cb["plain functions / callbacks
(see RFC #1111)"] + + Observers -->|yes| RmObs["remove them -- observers do not
fire for @tracked updates"] + RmObs --> Derive{"didReceiveAttrs / @computed
deriving data?"} + Observers -->|no| Derive + Derive -->|yes| Getter["native getters;
@cached if expensive"] + + Actions -->|yes| Methods["@action methods,
called directly"] + Positional -->|yes| Named["named arguments,
at every call site"] + + On --> Swap + Mod --> Swap + Ddau --> Swap + Cb --> Swap + Getter --> Swap + Methods --> Swap + Named --> Swap + + Swap["one pass, per component: swap '@ember/component' β†’ '@glimmer/component',
this.foo β†’ this.args.foo ({{@foo}} in the template), init() β†’ constructor(),
and local this.set() state β†’ @tracked (ember-tracked-properties-codemod)"] + Swap --> Gjs{"want template tag / gjs? (RFC #779)
note: does not have to wait for the swap --
gjs/gts works with '@ember/component' too,
any time after native class syntax + colocation"} + Gjs -->|yes| TT[["@embroider/template-tag-codemod
(only needs colocation +
native class syntax)"]] + TT --> Done + Gjs -->|no| Done(["no more '@ember/component'"]) +``` + +"Leaves first": computed properties cannot depend on native getters or tracked data, so convert components that do not feed data into still-unconverted parents, then work up the tree. + +Longer walkthroughs of the same migration: + +- the official [Octane upgrade guide](https://guides.emberjs.com/v5.12.0/upgrading/current-edition/) -- recommends starting with components that have no two-way bindings, computed properties, or observers +- [Chris Krycho's phased migration guides](https://github.com/chriskrycho/octane-migration-guides) -- source of the leaves-first rule and of doing `@tracked` with the superclass swap +- the Ember Atlas recommended migration order (preserved in [Melanie Sumner's slides](https://noti.st/melsumner/Hl16PZ/slides)) -- source of "observers go before `@tracked`" +- community walkthroughs from [Lighthouse](https://dev.to/lighthouse-intelligence/the-road-from-ember-classic-to-glimmer-components-4hlc) and [Isaac Lee](https://crunchingnumbers.live/2019/12/23/rewriting-apps-in-ember-octane/) + +In no particular order: + +- **Get on native classes.** + - codemod: [ember-native-class-codemod](https://github.com/ember-codemods/ember-native-class-codemod) + - docs: [Native Classes upgrade guide](https://guides.emberjs.com/v5.12.0/upgrading/current-edition/native-classes/) +- **Flatten the element into the template**, then set `tagName = ''`. + - codemod: [tagless-ember-components-codemod](https://github.com/ember-codemods/tagless-ember-components-codemod) + - docs: [Glimmer Components upgrade guide](https://guides.emberjs.com/v5.12.0/upgrading/current-edition/glimmer-components/) +- **Replace event methods with `{{on}}`** on the element from step 2. + - docs: [`{{on}}` API](https://api.emberjs.com/ember/release/functions/@ember%2Fmodifier/on), [Actions, `{{on}}`, and `{{fn}}` upgrade guide](https://guides.emberjs.com/v5.12.0/upgrading/current-edition/action-on-and-fn/) +- **Move lifecycle hooks into modifiers.** + - docs: [Template Lifecycle, DOM, and Modifiers guide](https://guides.emberjs.com/release/components/template-lifecycle-dom-and-modifiers/), [ember-modifier](https://github.com/ember-modifier/ember-modifier) + - optional intermediate: [@ember/render-modifiers](https://github.com/emberjs/ember-render-modifiers) -- a migration tool only, not recommended for applications (see its README); skippable entirely +- **Untangle two-way bindings**: take a callback argument instead. + - the only step that changes the component's public interface -- review carefully + - docs: [Octane vs Classic cheat sheet](https://ember-learn.github.io/ember-octane-vs-classic-cheat-sheet/) (Data Down, Actions Up) +- **Replace `@computed` with getters.** + - observers first: they do not fire for `@tracked` updates, so remove them before anything they watch becomes tracked + - docs: [Tracked Properties upgrade guide](https://guides.emberjs.com/v5.12.0/upgrading/current-edition/tracked-properties/) +- **Swap the superclass, and adopt `@tracked` in the same pass.** + - codemod for the `@tracked` part: [ember-tracked-properties-codemod](https://github.com/ember-codemods/ember-tracked-properties-codemod) + - docs: [Glimmer Components upgrade guide](https://guides.emberjs.com/v5.12.0/upgrading/current-edition/glimmer-components/) covers the whole conversion (`this.args`, `constructor()`, ...) + - go leaves-first: a computed property in a not-yet-converted parent cannot depend on this component's new native getters +- **(Optional) convert to `