Stop Leaking Implementation Details of Built-in Components
Summary
In order to stop leaking implementation details of built-in components, we propose to:
- Deprecate importing the following modules
@ember/component/checkbox
@ember/component/text-area
@ember/component/text-field
@ember/routing/link-component
- Deprecate accessing the following properties on the
Ember
global Ember.Checkbox
Ember.LinkComponent
Ember.TextArea
Ember.TextField
Ember.TextSupport
(already private, no import path available)Ember.TargetActionSupport
(already private, no import path available)- Deprecate calling
reopen
orreopenClass
on the classes and mixins listed above, whether they were obtained through an import or theEmber
global - Deprecate calling
reopen
orreopenClass
on theEmber.Component
super class (which is also the default export of@ember/component
), but not when called on a subclass ofEmber.Component
other than those listed above - Deprecate calling
lookup
orfactoryFor
on anOwner
with the following specifiers component:input
component:link-to
component:textarea
component:-checkbox
(already considered private)component:-text-field
(already considered private)template:component/input
template:component/link-to
template:component/textarea
template:component/-checkbox
(already considered private)template:component/-text-field
(already considered private)- Deprecate overriding the following factories on an
Owner
, through placing files the corresponding locations in theapp
tree or through any other means such as runtime registrations or using a customResolver
component:-checkbox
component:-text-field
template:component/input
template:component/link-to
template:component/textarea
template:component/-checkbox
(already considered private)template:component/-text-field
(already considered private)
Motivation
The ultimate goal is to stop leaking implementation details of built-in
components and be able to move away from subclassing Ember.Component
in their
internal implementations.
This RFC is part of a bigger plan to accomplish this goal, which requires at least one other follow-up RFC. This section will attempt to provide context for this overall plan. Please note that this RFC does not attempt to address every problem mentioned here and should ultimately be evaluated on its own merits.
The Problems
Ember's built-in components, that is <Input>
, <Textarea>
and <LinkTo>
,
are currently implemented as subclasses of Ember.Component
, also known as the
"classic" component API. This made sense historically, as that was the only
component API available in Ember at the time. While this is arguably an
implementation detail, in practice, this aspect of their implementations has
leaked and become relied upon.
Since then, we have identified some design flaws of the Ember.Component
API,
which motivated the Custom Components and
Glimmer Components API, the latter of which
became the primary component programming model in modern Ember as of the
Octane Edition.
The same design flaws of Ember.Component
that impacted Ember app developers
have also had an impact on these built-in components, and we would like to move
away from their legacy implementation for largely the same reason as Ember app
developers.
The most concerning aspect of these design flaws was that any named arguments passed to a classic component during invocation will be set as properties on the component instance. For example:
{{!-- This is NOT supported, do not do this! --}}
<Input
@init={{this.customInit}}
@willDestroyElement={{this.customWillDestroyElement}}
/>
Here, the @init
and @willDestroyElement
arguments will be made available as
instance.init
and instance.willDestroyElement
, essentially replacing and
overriding the init
and willDestroyElement
methods on the component class,
which are part of the Ember.Object
and Ember.Component
API, respectively.
Clearly, this is not something that we intended to support, but it is simply a
consequence that falls out of the design of the Ember.Component
API design.
A more likely example of this is the event handling hooks that we inherited
from Ember.Component
. In classic components, the way to handle events on the
component's element is to implement hooks like click()
and keyDown()
on the
component class, which is what the implementation of the component does.
Even though these hooks considered private implementation details (and clearly marked as such), developers have observed that they can pass callbacks into arguments of the same name during invocation, and they will be called when the corresponding events are dispatched to the components. For example:
{{!-- This is NOT supported, do not do this! --}}
<Input
@click={{this.myClickCallback}}
@focusIn={{this.myFocusInCallback}}
@keyDown={{this.myKeyDownCallback}}
/>
This is essentially the same category of bugs as passing @init
as an
argument, but it has become relatively common practice and even made it into
the official guides at one point (this is currently being addressed).
On top of this, until Angle Bracket Invocations
were introduced, there was no convenient way of passing HTML attributes to a
component. Components implemented using the Ember.Component
API relied on the
attributeBindings
API to map instance properties to HTML attributes.
The built-in components were historically bound by the same constraints, so they are documented to take a large amount of HTML attributes as arguments.
Post-Octane, this has caused some confusion as to what should be passed as arguments and what should be passed as HTML attributes using the angle bracket invocation syntax. With a few rare exceptions, the latter should suffice and the named arguments are mostly obsolete at this point.
Similarly, until modifiers were available
with Angle Bracket Invocations and the
{{on}} Modifier was introduced, there was no first-class
API for listening to DOM events on components you do not control. This required
the component authors to enumerate events they want to expose and accept them
as callback arguments, manually dispatching them in the Ember.Component
event-handling hooks.
The built-in components were historically bound by the same constraints here
as well. Before modifiers, the documented way
of handling events on the built-in input components are via the "dasherized"
named arguments, such as <Input @key-down={{this.myAction}} />
.
However, as mentioned above, due to the way arguments are handled, the event
handling hooks inherited from Ember.Component
were frequently misused
instead, which would prevent these "dasherized" callbacks from running, as they
would overwrite the re-dispatching logic.
On top of this, there has also been a bug that prevented these callback arguments from working reliably. Unfortunately, this was not investigated in a timely manner and was only fixed very recently. This nudged developers towards the incorrect approaches even more and further propagated the confusion, some of which has made their way into the official learning materials (this is currently being addressed).
In addition, for a variety of historical reasons, the implementation of the
built-in components, that is, the Checkbox
, LinkComponent
, TextArea
and
TextField
component classes were documented as public API, as well as the
TextSupport
and TargetActionSupport
mixins, which are considered private
but are well-documented and accessible through the Ember
global.
Because of this, the fact that they are implemented as Ember.Component
subclasses are highly observable. Some common use cases and consequences are:
These classes can be imported for subclassing. For example, an app can export a custom subclass of
TextField
at, say,app/components/my-text-field.js
which can then be invoked as. These classes can be reopened to modify the behavior of the built-in components. For example, this will add a CSS class to all
<Input>
components rendered in the app:
import TextField from '@ember/component/text-field';
TextField.reopen({
classNames: ['i-am-a-text-field']
});
Likewise, reopenClass
can be used to modify the class itself.
- Similar to the above, reopening the
Ember.Component
super class will affect all built-in components as well. For example, this will also add a CSS class to all built-in components rendered in the app:
import Component from '@ember/component';
Component.reopen({
classNames: ['i-am-a-component']
});
Likewise, reopenClass
can be used to modify the super class itself, which
will be inherited by the built-in component classes.
- It is possible to lookup (or even replace) the built-in components using the
owner.factoryFor
andowner.lookup
runtime APIs.
These are all opportunities for the internal implementation to leak, therefore
making changes to how the built-in components are implemented – specifically
stop subclassing from Ember.Component
– will likely be a breaking change for
some, even if the new implementations otherwise supported all documented APIs
perfectly.
The Plan
At their core, the designs of the built-in components are rather simple. Take
the <Input>
component for example, its job is to provide a convince on top of
the native <input>
element. It accepts @type
and @value
(or @checked
)
as arguments and keeps the underlying <input>
element in sync with the given
app state.
That description should cover 90% of what the average developer need to know
about this component. It should feel pretty similar to using any other modern
components in the Ember ecosystem – attributes can be passed in angle bracket
invocation, and native events can be listened to with the {{on}}
modifier.
There is a little bit extra this component does – it takes callbacks for some
higher-level "logical events" such as @enter
and @escape-press
. Arguably
these aren't strictly necessary with the ubiquity of the {{on}}
modifier or
specialized addons like ember-keyboard.
Perhaps we wouldn't have introduced these features today, but since there is
not a direct 1:1 translation to migrate away and there aren't really anything
wrong with them, we are not proposing to remove them to avoid needless churn.
Beyond that, everything else that is now redundant or obselete (such as named arguments that exists purely for binding HTML attributes and callback arguments that have corresponding native events) should be deprecated and removed.
Concretely, the plan is to:
- Deprecate and remove any mechanisms that could leak the implementation
- Deprecate and remove any features that are specific to
Ember.Component
- Deprecate and remove any redundant or obselete features
This RFC focuses on the first step, and the rest will be proposed separately in follow-up RFC(s).
Detailed design
This RFC proposes the following deprecations:
- Deprecate importing the following modules
@ember/component/checkbox
@ember/component/text-area
@ember/component/text-field
@ember/routing/link-component
- Deprecate accessing the following properties on the
Ember
global Ember.Checkbox
Ember.LinkComponent
Ember.TextArea
Ember.TextField
Ember.TextSupport
(already private, no import path available)Ember.TargetActionSupport
(already private, no import path available)- Deprecate calling
reopen
orreopenClass
on the classes and mixins listed above, whether they were obtained through an import or theEmber
global - Deprecate calling
reopen
orreopenClass
on theEmber.Component
super class (which is also the default export of@ember/component
), but not when called on a subclass ofEmber.Component
other than those listed above - Deprecate calling
lookup
orfactoryFor
on anOwner
with the following specifiers component:input
component:link-to
component:textarea
component:-checkbox
(already considered private)component:-text-field
(already considered private)template:component/input
template:component/link-to
template:component/textarea
template:component/-checkbox
(already considered private)template:component/-text-field
(already considered private)- Deprecate overriding the following factories on an
Owner
, through placing files the corresponding locations in theapp
tree or through any other means such as runtime registrations or using a customResolver
component:-checkbox
component:-text-field
template:component/input
template:component/link-to
template:component/textarea
template:component/-checkbox
(already considered private)template:component/-text-field
(already considered private)
The first two sets of deprecations removes the classes themselves from being public APIs.
In order to support apps that have implemented custom components by subclassing
these built-in classes, the current implementations of the Ember.Checkbox
,
Ember.LinkComponent
, Ember.TextArea
and Ember.TextField
classes will be
moved to a legacy addon and remain "frozen" in there. Future versions of Ember
will stop basing the built-in components on these legacy implementations, but
custom subclasses will continue to work. The deprecation message should provide
information about the legacy addon, or link to the deprecation details page
with the relevant information.
The private mixins, on the other hand, will be deprecated without replacement.
Note that in accordance with RFC #496, the following import paths will be made available for use in strict mode:
Input
(import { Input } from '@ember/component
)LinkTo
(import { LinkTo } from '@ember/routing
)Textarea
(import { Textarea } from '@ember/component'
)
However, unlike the deprecated import paths in group 1, these modules provide opaque values that are intended for use in templates only. Since they do not expose the implementation details of the built-in components, these new import paths do not have the same issue described in this RFC.
The third and forth prevents globally modifiying the behavior of built-in components. Users are encouraged to create wrapper components for use in their apps or create custom subclasses using the legacy addon.
The fifth deprecation prevents leakage of the classes to runtime code, which is complimentary to the first two sets of deprecations.
The last group of deprecation prevents partially replacing the built-in components, such as replacing only the template but not the class.
Notably, the last group of deprecation does not prevent replacing the built-in components in general, by defining a component with the same name. While this is not necessarily encouraged and would certainly be confusing for developers and tools alike, we do not intend to "reserve" the built-in component names, so if an app provides a component with the same name, they will take precedence over the built-in components.
Transition Path
Importing and Accessing Globals For Subclassing
Example deprecation message:
Using Ember.Checkbox or importing from '@ember/component/checkbox' has been
deprecated, install the `@ember/legacy-built-in-components` addon and use
`import { Checkbox } from '@ember/legacy-built-in-components';` instead.
Example that causes deprecation:
import Checkbox from '@ember/component/checkbox';
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Using Ember.Checkbox or importing from '@ember/component/checkbox' has been
// deprecated, install the `@ember/legacy-built-in-components` addon and use
// `import { Checkbox } from '@ember/legacy-built-in-components';` instead.
export class MyCheckbox extends Checkbox {
// ...
}
Fixed example:
import { Checkbox } from '@ember/legacy-built-in-components';
export class MyCheckbox extends Checkbox {
// ...
}
Alternative example that triggers the deprecation:
export function initialize(owner) {
owner.register(
'component:my-checkbox',
Ember.Checkbox.extend({ /* ... */ })
// ~~~~~~~~~~~~~~
// Using Ember.Checkbox or importing from '@ember/component/checkbox' has been
// deprecated, install the `@ember/legacy-built-in-components` addon and use
// `import { Checkbox } from '@ember/legacy-built-in-components';` instead.
);
}
Fixed example:
import { Checkbox } from '@ember/legacy-built-in-components';
export function initialize(owner) {
owner.register(
'component:my-checkbox',
Checkbox.extend({ /* ... */ })
);
}
Additional prose in the deprecation guide:
The implementation of the built-in components are no longer based on these legacy classes, so they are now unused by the framework and will be removed in the future.
If you have implemented custom subclasses of these components, you can install the
@ember/legacy-built-in-components
addon. This addon vendors the legacy classes and make them available for import. See the addon's README for more details.Note that this addon merely makes the legacy classes available, it does not "restore" the built-in components' implementation to be based on these legacy classes.
Reopening Legacy Built-in Component Classes
Example deprecation message:
Reopening Ember.Checkbox has been deprecated. Consider implementing your own
wrapper component or create a custom subclass.
Example that causes deprecation:
import Checkbox from '@ember/component/checkbox';
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// This still triggers the deprecation from above
Checkbox.reopen({
// ~~~~~~~
// Reopening Ember.Checkbox has been deprecated. Consider implementing your own
// wrapper component or create a custom subclass.
attributeBindings: ['metadata:data-my-metadata'],
metadata: ''
});
Fixed example:
{{!-- app/components/my-checkbox.hbs --}}
<Input
@type="checkbox"
@checked={{@checked}}
...attributes
data-my-metadata={{@metadata}}
/>
Alternative example that triggers the deprecation:
import Checkbox from '@ember/component/checkbox';
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
// This still triggers the deprecation from above
Checkbox.reopen({
// ~~~~~~~
// Reopening Ember.Checkbox has been deprecated. Consider implementing your own
// wrapper component or create a custom subclass.
change(...args) {
console.log('changed');
this._super(...args);
}
})
Fixed example:
// app/components/my-checkbox.js
import { Checkbox } from '@ember/legacy-built-in-components';
export default class MyCheckbox extends Checkbox {
change(...args) {
console.log('changed');
super.change(...args);
}
}
Additional prose in the deprecation guide:
The implementation of the built-in components are no longer based on these legacy classes, so reopening them will soon have no effect.
To customize the behavior of these built-in components, create your own wrapper components as a template-only component and invoke that instead.
Alternatively, you may also implement your own customized version of the component installing the
@ember/legacy-built-in-components
addon. This addon vendors the legacy classes and make them available for subclassing. See the addon's README for more details.Note that this addon merely makes the legacy classes available, it does not "restore" the built-in components' implementation to be based on these legacy classes. You cannot simply reopen the classes provided by this addon.
Reopening Classic Component Super Class
Example deprecation message:
Reopening the Ember.Component super class itself has been deprecated. Consider
alternatives such as installing event listeners on the document or add the
customizations to specific subclasses.
Example that causes deprecation:
import Component from '@ember/component';
Component.reopen({
// ~~~~~~~
// Reopening the Ember.Component super class itself has been deprecated. Consider
// alternatives such as installing event listeners on the document or add the
// customizations to specific subclasses.
click() {
console.log('Clicked on a classic component');
}
});
Fixed example:
document.addEventListener('click', event => {
if (e.target.classList.contains('ember-view')) {
console.log('Clicked on a classic component');
}
});
Alternative example that triggers the deprecation:
import Component from '@ember/component';
Component.reopen({
// ~~~~~~~
// Reopening the Ember.Component super class itself has been deprecated. Consider
// alternatives such as installing event listeners on the document or add the
// customizations to specific subclasses.
attributeBindings: ['metadata:data-my-metadata'],
metadata: ''
});
Fixed example:
// app/components/base.js
import Component from '@ember/component';
// Subclass from this in your app, instead of subclassing from Ember.Component
export default Component.extend({
attributeBindings: ['metadata:data-my-metadata'],
metadata: ''
});
Additional prose in the deprecation guide:
Reopening a the
Ember.Component
super class is dangerous and has far-reaching consequences. For example, it may unexpectedly break addons that are not expecting the changes.To respond to DOM events globally, consider using global event listeners instead.
Alternatively, you may create a custom subclass of
Ember.Component
with the behavior you want and subclass from that in your app. That way, only those components which explictly opted into the changes will be affected.
Runtime Lookups
Example deprecation message:
Looking up `component:input` is deprecated. The implementations of the built-in
components are considered private and should not be relied upon. In the future,
the runtime registry will no longer contain entries for built-in components.
Example that causes deprecation:
this.owner.lookup('component:input');
Fixed example:
(None)
Partial Registration Override
Example deprecation message:
Registering `component:-checkbox` or having a `app/components/-checkbox`
module in your app is deprecated. To replace the `<Input>` built-in component,
you must fully replace it in its entirity, by registering `component:input` or
defining a `app/components/input` module.
Example that causes deprecation:
// app/components/-checkbox.js
import Component from '@glimmer/component';
export default class MyBetterCheckbox extends Component {
// ...only handles <Input @type="checkbox" />
}
Fixed example:
// app/components/input.js
import Component from '@glimmer/component';
export default class MyBetterInput extends Component {
// ...must handle all cases of <Input />
}
How we teach this
The API documentation should be updated to document the built-in components as components, not as classes. When reading the documentation, developers should be able to understand the components in terms of what arguments they are able to pass. The exact format for documenting components is left unspecified to provide the learning team some flexibility in accomplish this goal.
Drawbacks
Apps that heavily customizes the built-in components will have to put in some work to migrate, though this is largely mitigated by making the existing implementations available through the legacy addon.
Alternatives
We can leave the existing built-in components around, ship new, modernized versions under new names, without deprecating the old ones. This will create more cruft and confusion in the long run, as may be difficult for developers to determine which ones are the recommended ones.
We can leave the existing built-in components around, ship new, modernized versions under new names, and deprecating the old ones. Assuming the new components are roughly a subset of the existing ones, this will have more or less the same end result as the plan laid out above, but possibly with more intermediate churn.
In this case, developers would have to figure out how to refactor away from legacy features that are no longer supported by the new version before they could fully migrate, similar to how developers are migrating from classic to Glimmer components today.
By deprecating and removing individual features, as proposed in this plan, we will be able to provide more directly actionable guidance in each case.
We can remove the built-in components altogether. This is a much bigger lift that would require analyising all existing use cases and likely requires designing new capabilities and features to fill some of those gaps.
Other than the problems outlined in this RFC, the built-in components mostly get the job done adequately, and we feel like there is not a lot of value in taking on the project to reimagine them significantly and force the entire community to migrate to completely new patterns at this moment.
Our preference would therefore be to pare down the existing components to address the problems raised here. In the meantime, we will work on providing primitives and capabilities that underpins these built-in components (such as router helpers), so the community can experiment with alternatives and new patterns.
If and when they mature to the point of becoming widely accepted as best practices in the ecosystem, we can revisit deprecating and removing any of these built-in components as they become redundant. Broadly speaking, we do not believe we are at that point yet, so in the meantime, we should not stop improving what we have.
Unresolved questions
None.