From 38e84abf8aa023ef1d08cf18684db5613d147179 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 13 Mar 2017 16:00:29 -0700 Subject: [PATCH 01/13] Add custom components RFC --- text/0000-custom-components.md | 1078 ++++++++++++++++++++++++++++++++ 1 file changed, 1078 insertions(+) create mode 100644 text/0000-custom-components.md diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md new file mode 100644 index 0000000000..385b7d00d7 --- /dev/null +++ b/text/0000-custom-components.md @@ -0,0 +1,1078 @@ +- Start Date: 2017-03-13 +- RFC PR: (leave this empty) +- Ember Issue: (leave this empty) + +# Summary + +This RFC aims to expose a _low-level primitive_ for defining _custom +components_. This API will allow addon authors to implement special-purpose +component-based APIs (such as LiquidFire's animation helpers or low-overhead +components for performance hotspots). + +In the medium term, this API and expected future enhancements will enable the +Ember community to experiment with alternative component APIs outside of the +core framework, for example enabling the community to prototype "angle bracket +components" using public APIs outside of the core framework. + +# Motivation + +The ability to author reusable, composable components is a core features of +the Ember.js framework. Despite being a [last-minute addition](http://emberjs.com/blog/2013/06/23/ember-1-0-rc6.html) +to Ember.js 1.0, the `Ember.Component` API has proven itself to be an extremely +powerful programming model and has aged well over time into the primary unit of +composition in the Ember view layer. + +That being said, the current component API (hereinafter "classic components") +does have some noticeable shortcomings. Over time, classic components have also +accumulated some cruft due to backwards compatibility constraints. + +These problems led to the original "angle bracket components" proposal (see RFC +[#15](https://github.com/emberjs/rfcs/blob/master/text/0015-the-road-to-ember-2-0.md) +and [#60](https://github.com/emberjs/rfcs/pull/60)), which promised to address +these problems via the angle bracket invocation opt-in (i.e. `` +instead of `{{foo-bar ...}}`). + +Since the transition to the angle bracket invocation syntax was seen as a rare, +once-in-a-lifetime opportunity, it became very tempting for the Ember core team +and the wider community to debate all the problems, shortcomings and desirable +features in the classic components API and attempt to design solutions for all +of them. + +While that discussion was very helpful in capturing constraints and guiding the +overall direction, designing that One True API™ in the abstract turned out to +be extremely difficult and ultimately undesirable. It also went against the +Ember philosophy that framework features should be extracted from applications +and designed iteratively with feedback from real-world usage. + +Since those proposals, we have rewritten Ember's rendering engine from the ground +up (the "Glimmer 2" project). One of the goals of the Glimmer 2 effort was to +build first-class primitives for our view-layer features in the rendering engine. +As part of the process, we worked to rationalize these features and to re-think +the role of components in Ember.js. This execrise has brought plenty of new ideas +and constraints to the table. + +The initial Glimmer 2 integration was completed in [November](http://emberjs.com/blog/2016/11/30/ember-2-10-released.html). +Since Ember.js 2.10, classic components have been re-implemented on top of the +Glimmer 2 primitives, and we are very happy with the results. + +This approach yielded a number of very powerful and flexible primitives: +in addition to classic components, we were able to implement the `{{mount}}`, +`{{outlet}}` and `{{render}}` helpers from Ember as "components" under the hood. + +Based on our experience, we believe it would be beneficial to open up these new +primitives to the wider community. Specifically, there are at least two clear +benefits that comes to mind: + +First, this would unlock new capabilities for addon authors, allowing them to +build custom components tailored to specific scenarios that are underserved by +the general-purpose component APIs (e.g. Liquid Fire's animation helpers, +low-overhead components for performance hotspots). Having an escape valve +for these scenarios also allows us to focus primarily on the mainstream use cases +when designing the new component API ("angle bracket components"). + +Second, this API and expected future enhancements will enable the Ember community +to experiment with alternative component APIs, and allow us to prototype "angle +bracket components" outside of the core framework purely on top of exposed public +APIs. + +Following the success of FastBoot and Engines, we believe the best way to design +angle bracket components is to first stablize the underlying primitives in the +core framework and then experiment with the surface API through an addon. + +# Detailed design + +## What is a component? + +In today's programming model, a component is often viewed narrowly as a device +for handling and managing user-interactions ("UI widgets"). + +While components are indeed very useful for building widgets, it doesn't just +stop there. In front-end development, they serve a much broader role, allowing +you to break up large templates into smaller, well-encapsulated units. + +For example, you might break up a blog post into a headline, byline, body, +and a list of comments. Each comment might be further broken down into +a headline, author card and contents. + +From Glimmer's perspective, components are analogous to functions in +other programming languages. Some components are designed to be reusable +in many contexts, but it's also perfect normal to use them to break apart +large chunks of logic. + +In the most general sense, a component takes inputs (positional arguments, +named arguments, blocks, etc.), can be invoked, may render some content, +and knows how to keep its content up to date as its inputs change. From +the outside, you don't need to know how these details are managed, you +just need to know its API (i.e. what inputs it expects). + +When looking at components expansively, it's no surprise that things that +are not usually thought of as components are implemented as components +in the template layer (input and link helpers, engines, outlets). Similarly, +we expect that this API to be useful far beyond just "UI widgets". + +## `ComponentDefinition` + +This RFC introduces a new type of object in Ember.js, `ComponentDefinition`, +which defines a component that can be invoked from a template. + +Like classic components, a `ComponentDefinition` must be registered with a +dasherized name (with at least one dash in the name). This allows them to be +easily distinguishable from regular property lookups and HTML elements. Once +registered, the component can be invoked by name like a regular classic +component (i.e. `{{foo-bar}}`, `{{foo-bar "positional" "and" named="args"}}`, +`{{#foo-bar with or without=args}}...{{/foo-bar}}` etc). + +> **Open Question**: How should these objects be registered? (see the last + section of this RFC) + +> **Open Question**: How does this interact with the `{{component}}` helper +and the `(component)` feature? (see the last section of this RFC) + +A `ComponentDefinition` object should satisfy the following interface: + +```typescript +interface ComponentDefinition { + name: string; + layout: string; + manager: string; + capabilities: ComponentCapabilitiesMask; + metadata?: any; +} +``` + +> **Open Question**: Should we require `ComponentDefinition` to inherit from a +provided super class (`Ember.ComponentDefinition.extend({ ... }`) or otherwise +be wrapped with a function call (`Ember.Component.definition({ ... })`)? + +The *name* property should contain an identifier for this component, usually +the component's dasherized name. This is primarily used by debug tools (e.g. +Ember Inspector). + +The *layout* property specifies which template should be rendered when the +component is invoked. For example, if "foo-bar" is specified, Ember will lookup +the template `template:components/foo-bar` from the resolver. + +> **Open Question**: How does this interact with local lookup? + +The *manager* property is a string key specifying the `ComponentManager` to use +with this component. For example, if "foo" is specified here, Ember will lookup +the component manager `component-manager:foo`. This will be described in more +detail in the sections below. + +> **Note**: Specifying the manager as a lookup key allows the component manger +to receive injections. + +The *capabilities* property specifies the optional features required by this +component. This will be described in more detail below. + +Finally, there is an optional *metadata* property which component authors can +use to store arbitrary data. For example, it may include a *class* property to +specify which component class to use. The *metadata* property is ignored by +Ember but can be used by the `ComponentManager` to perform custom logic. + +## `ComponentManager` + +Whereas a `ComponentDefinition` describe the static property of the component, +a `ComponentManager` controls its runtime properties. + +A basic `ComponentManager` satisfies the following interface: + +```typescript +interface ComponentManager { + create(definition: ComponentDefinition, args: ComponentArguments): T; + getContext(instance: T): any; + update(instance: T, args: ComponentArguments): void; +} + +interface ComponentArguments { + positional: any[]; + named: Object; +} +``` + +> **Open Question**: Should we require `ComponentManager` to inherit from a +provided super class (`Ember.ComponentManager.extend({ ... }`)? + +When Ember is about to render a component, Ember will lookup its component +manager (as described above) and call its *create* method with the component +defition and the *component arguments*. + +The *component arguments* object is a snapshot of the arguments pased into the +component. It has a *positional* and *named* property which corresponds to an +array and object (dictionary) of the current argument values. + +For example, for the following invocation: + +```hbs +{{blog-post (titleize post.title) post.body author=post.author excerpt=true}} +``` + +Glimmer will look up the `blog-post` definition and call *create* on its +manager with the following `ComponentArguments`: + +```javascript +{ + positional: [ + "Rails Is Omakase", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit..." + ], + named: { + "author": #, + "excerpt": true + } +} +``` + +The component manager *must not* mutate the *component arguments* object (and +the inner *positional* and *named* object/array) directly as they might be +pooled or reused by the system. + +> **Note**: We should probably freeze them in debug mode. + +Based on the information in the definition and the *component arguments*, the +manager should return a *component instance* from the *create* method. From +Ember's perspective, this could be any arbitrary value – it is only used for +the component manager's internal book-keeping. In practice, this value should +store enough information to represent the internal state of the component. For +these reasons, it is often referred to as the "opaque state bucket". + +At a later time, before Ember is ready to render the template, the component +manager's *getContext* method will be called. It will receive the *component +instance* and return the context object for the template. The context binds +`{{this}}` inside the layout, which is also the root of implicit property +lookups (e.g. `{{foo}}`, `{{foo.bar}}` are equivalent to `{{this.foo}}` and +`{{this.foo.bar}}`). + +In many cases, the component manager can simply return the *component instance* +from this hook: + +```typescript +class MyManager implements ComponentManager { + create(definition: ComponentDefinition, args: ComponentArguments): MyComponent { + ... + } + + getContext(instance: MyComponent): MyComponent { + return instance; + } + + update(instance: MyComponent, args: ComponentArguments) { + ... + } +} +``` + +However, they could also choose derive a different value from the state bucket. +This pattern allows the component manager to hide internal metadata: + +```typescript +interface MyStateBucket { + instance: MyComponent; + secret: any; +} + +class MyManager implements ComponentManager { + create(definition: ComponentDefinition, args: ComponentArguments): MyStateBucket { + ... + } + + getContext({ instance }: MyStateBucket): MyComponent { + return instance; + } + + update({ instance, secret }: MyStateBucket, args: ComponentArguments) { + ... + } +} +``` + +Finally, when any of the *component arguments* have changed, Ember will call +the *update* method on the manager, passing the *component instance* and a +snapshot of the current *component arguments* value. This happens before the +template is revalidated, therefore any updates to the context object performed +here will be reflected in the template. + +Ember does not provide a fine-grained change tracking system, so there is no +guarantee on how often *update* will be called. More precisely, if on of the +*component arguments* have changed to a different value, *update* _will_ be +called at least once; however, the reverse is not true – when *update* is +called, it does not guarantee that at least one of the *component arguments* +will have a different value. + +For example, given the following invocation: + +```hbs +{{my-component unit=(pluralize "cat" count=cats.count)}} +``` + +If `cats.count` changes from 3 to 4, the *pluralize* helper will return exactly +the same value (the string `"cats"`), therefore from the manager's perspective, +the *component arguments* have not changed. However, in today's implementation, +the *update* method will still be called in this case. Over time, we intend for +internal changes in the implementation to cause this method to be called less +often, and other changes may cause it to be called more often. As a result, +component managers should not rely on this detail. + +Here is a simple example that puts all of these pieces together: + +```typescript +interface MyStateBucket { + immutable: boolean; + instance: Object; +} + +class MyManager implements ComponentManager { + // on initial invocation, turn the arguments into a per-instance state bucket + // that contains the component instance and some other internal details + create(definition: ComponentDefinition, args: ComponentArguments): MyStateBucket { + let immutable = Math.random() > 0.5; + let instance = getOwner(this).lookup(`component:${definition.metadata.class}`); + + instance.setProperties(args.named); + + return { secret, instance }; + } + + // expose the component instance (but not the internal details) as + // {{this}} in the template + getContext({ instance }: MyStateBucket): any { + return instance; + } + + // when update is called, update the properties on the component with the + // new values of the named arguments. + update({ immutable, instance }: MyStateBucket, args: ComponentArguments) { + if (!immutable) { + instance.setProperties(args.named); + } + } +} +``` + +While each invocable component needs its own `ComponentDefinition`, a single +`ComponentManager` instance can create and manage many *component instances* +(and this is why all of the hooks takes the *component instance* as the first +parameter). In fact, all classic components in Ember today share the same +component manger. Therefore, it is important to follow the pattern of keeping +your component's state in the state bucket, rather than storing it on the +manager itself. + +That being said, in some rare cases, it might make sense to store some shared +states on the manager. For example, a component manager might want to maintain +a pool of *component instances* and reuse them when possible (e.g. `{{sustain}}` +in flexi). In this case, it would make sense to store the components pool on +the manager: + +```typescript +class PooledManager implements ComponentManager { + + private pools = {}; + + create({ metadata }: ComponentDefinition, args: ComponentArguments): Object { + let pool = this.pools[meatadata.name]; + let instance; + + if (pool && pool.length) { + instance = pool.pop(); + } else { + instance = getOwner(this).lookup(`component:${metadata.class}`); + } + + instance.setProperties(args.named); + + return instance; + } + + ... + +} +``` + +## `ComponentCapabilitiesMask` + +The *capabilities* property on a `ComponentDefinition` describes the optional +features required by this component. There are several reasons for this design. + +First of all, performance-wise, this allows us to make the costs associated +with individual features PAYGO (pay-as-you-go). The Glimmer architecture makes +it possible to break down the work of component invocation into small pieces. + +For example, a "template-only component" doesn't need Glimmer to invoke any +the hooks related to lifecycle management of the *component instance*. +Therefore, in an ideal world, they should not pay the cost associated with +invoking those hooks. + +To accomplish this, we designed the component manager to only expose a minimal +set of features by default and allow individual components to opt-in to additional +features as needed. The capabilities mask is the mechanism for components to +signal that. + +Second, it allows us to introduce new features and make improvements to this +API without breaking existing code. In addition to enumerating the required +features, the `ComponentCapabilitiesMask` also encodes the version of Ember.js +the `ComponentDefinition`/`ComponentManager` was developed for. This makes it +possible to, say, make angle bracket invocation the default for new components +but preserve the current default ("curly" invocation) for existing components +that were targeting and older API revision. + +Ember.js will provide a function to construct a `ComponentCapabilitiesMask`. +Since this is a required property, at minimum, a component definition would +specify the following: + +```js +import Ember from 'ember'; + +const { capabilities, definition } from Ember.Component; + +export default definition({ + name: "foo-bar", + capabilities: capabilities("2.13"), + ... +}); +``` + +The first argument to the function is a string specifying the Ember.js version +(up to the minor version) this component is targeting. As mentioned above, this +allows us to gracefully handle API changes between versions. This is important +because the raw, low-level APIs in Glimmer are still highly unstable. + +Changes to the Glimmer APIs at this layer can significantly improve the overall +performance of the component system by changing its internal implementation. +Even though the surface API is still under active development, we would like to +start exposing the stable parts of this system to Ember addon authors as soon +as possible. + +The version identifier in the capabilities mask acts as a safety net. It allows +Ember to absorb the churn and avoid breaking existing code. + +For example, the API proposed in this RFC supplies **reified** *component +arguments* to the component manager's *create* and *update* methods, i.e. a +"snapshot" of the current *component arguments* values, which imposes some +unnecessary cost. Eventually, we would like to address this problem by moving +to a [reference](https://github.com/tildeio/glimmer/blob/master/guides/04-references.md)-based +API. + +Imagine that, by the time Ember 2.14 comes around, we were able to stablize the +more performant reference-based APIs. Instead of the reified *component +arguments* snapshot, new components targeting Ember 2.14 (i.e. `capabilities("2.14")`) +will receive the reference-based *component arguments* objects instead. +However, existing components that were written before Ember 2.14 (e.g. +`capabilities("2.13")`) will continue to receive the reified *component +arguments* for backwards compatability. + +The second argument allows components to opt-in to additional features that are +not enabled by default: + +```js +import Ember from 'ember'; + +const { capabilities, definition } from Ember.Component; + +export default definition({ + name: "foo-bar", + capabilities: capabilities("2.13", { + someFeature: true, + anotherFeature: true + }), + ... +}); +``` + +Under the hood, this will likely be implemented as a bit mask, but component +authors should treat it as an opaque value intended for internal use by the +framework only. There will not be any public APIs to introspect its value (e.g. +`isEnabled()`), so component mangers should not rely on this to alter its +runtime behavior. + +All features are additive. The default set of functionality for a particular +version is always the most lightweight component available at the time. + +### Optional Features + +Below is list of optional features supported in the initial implementation. +This list is intentionally kept minimal to limit the scope of this RFC. + +Additional features (e.g. angle bracket invocation, references-based APIs, +providing access to the component element, event handling/delegation) should be +proposed in their own RFCs (they can even be proposed _in parallel_ to this +RFC) which allows for a more focused discussions around those topics. + +* `asyncLifecycleHooks` + + When this feature is enabled, the `ComponentManager` should implement this + extended interface: + + ```typescript + interface WithAsyncHooks extends ComponentManager { + didCreate(instance: T): void; + didUpdate(instance: T): void; + } + ``` + + These methods will be called with the *component instance* ("state bucket") + *after the rendering process is complete*. This would be the right time to + invoke any user callbacks, such as *didInsertElement* and *didRender* in the + classic components API. + + It is guaranteed that the async callbacks will the invoked if and only if the + synchronous APIs were invoked during rendering. For example, if *update* is + called on a *component instance* during rendering, it is guaranteed *didUpdate* + will be called. Conversely, if *didUpdate* is called, it is guaranteed that + that *component instance* was updated during rendering (per the semantics of + the *update* method described above). + + > **Open Question**: Should we provide ordering guarantee? + +* `destructor` + + When this feature is enabled, the `ComponentManager` should implement this + extended interface: + + ```typescript + interface WithDestructor extends ComponentManager { + destroy(instance: T): void; + } + ``` + + The *destroy* method will be called with the *component instance* ("state + bucket") when the component is no longer needed. This is intended for + performing object-model level cleanup. It might also be useful for niche + scenarios such as recycling *component instances* in the "component pool" + example mentioned above. + + Because this RFC does not provide ways access to the component DOM tree, the + timing relative to DOM teardown is undefined (i.e. whether this is called + before or after the component's DOM tree is removed from the document). + Therefore, this hook is **not** suitable for invoking DOM cleanup hooks such + as *willDestroyElement* in the classic components API. We expect a subsequent + RFC addressing DOM-related functionalities to clearify this issues (and + most-likely provide a specialized hook for that purpose). + + Further, the exact timing for the call is also undefined. For example, the + calls from several render loops might be batched together and deferred into a + browser idle callback. + + > **Open Question**: Should we provide ordering guarantee? + +* `viewSupport` + + When this feature is enabled, the `ComponentManager` should implement this + extended interface: + + ```typescript + interface WithViewSupport extends ComponentManager { + getView(instance: T): Object; + } + ``` + + The *getView* method will be called with the *component instance* ("state + bucket") after the component has been created but before any children + components in its layout (or yielded blocks) are created. The manager should + return a view object corresponding to the *component instance* from this hook. + A lot of times, this will just be the *component instance* itself, but this + indirection allows you to hide (or intentionally expose) additional metadata. + + Ember will install a GUID on the returned object using `Ember.guidFor` and + add the view to the view registry with the GUID. Currently, `Ember.guidFor` + adds an internal property on the object to store the GUID, therefore the view + cannot be frozen or sealed. This restriction might change in the future as + we move to a `WeakMap`-based internal implementation. + + The view will also become visible to other components as a parent/child view. + If the parent view is an `Ember.Component`, it will see your component in its + *childViews* array. Similarly, when rendering other `Ember.Component` inside + your comoponent's layout (or yielded blocks), they will see your component as + their *parentView*. + + > **Open Question**: Will this break existing code? + + Note that these only applies to components that requests the *viewSupport* + capability. Components that do not have *viewSupport* enabled will essentially + be invisible in the Ember view hierarchy – they will not be added to the view + registry and the parent/child view tree will essentially "jump through" these + virtual views. At present, those components will also be invisible to debug + tools like the Ember Inspector, but subsequent RFCs may change that. + + Components that are conceptually "advanced flow-control syntax", such as + LiquidFire's *liquid-if*, most likely want to remain "virtual". On the other + hand, general-purpose component toolkits probably want to participate in the + view hierarchy like `Ember.Component`s do. + +# Examples + +> **Note**: For now, these examples side-steps the registration/resolution +> problem (see the "Unresolved Questions" section) by specifying the outcome +> of a particular container lookup (using the `type:name` convention) without +> addressing *how* they get in there in the first place. + +## Isolated Partials + +This example implements a very simple component API that acts more or less like +a partial in Ember. However, unlike partials, it will only have access to named +arguments that are explicitly passed in, instead of inheriting the caller's +scope. + +Given the following `ComponentDefinition`s: + +```javascript +// component-definition:site-header +{ + name: "site-header", + layout: "site-header", + manager: "isolated-partials", + capabilities: capabilities("2.13") +} +``` + +```javascript +// component-definition:site-footer +{ + name: "site-footer", + layout: "site-footer", + manager: "isolated-partials", + capabilities: capabilities("2.13") +} +``` + +```javascript +// component-definition:contact-us +{ + name: "contact-us", + layout: "contact-us", + manager: "isolated-partials", + capabilities: capabilities("2.13") +} +``` + +...and the following templates: + +```handlebars +{{!-- app/templates/application.hbs --}} + +{{site-header}} + +{{outlet}} + +{{site-footer copyrightYear="2017" company=model}} +``` + +```handlebars +{{!-- app/templates/components/site-header.hbs --}} + +
+

Welcome

+
+``` + +```handlebars +{{!-- app/templates/components/site-footer.hbs --}} + +
+ © {{copyrightYear}} {{company.name}}. + + {{contact-us tel=company.tel address=company.address}} +
+``` + +```handlebars +{{!-- app/templates/components/contact-us.hbs --}} + +
+

Contact Us

+ {{tel}} +
{{address}}
+
+``` + +...and the following component manager: + +```javascript +// component-manager:isolated-partials + +class IsolatedPartialsManager extends ComponentManager { + create(_definition: ComponentDefinition, args: ComponentArguments) { + return { ...args.named }; + } + + getContext(instance: Object): Object { + return instance; + } + + update(instance: Object, args: ComponentArguments): void { + Object.assign(instance, args.named); + } +} +``` + +...it would render something like this: + +```html +
+

Welcome

+
+ +... + +
+ © 2017 ACME Inc. + +
+

Contact Us

+ 1-800-ACME-INC +
100 Absolutely No Way, Portland, OR 98765
+
+
+``` + +This is probably most basic `ComponentManager` one could write. It essentially +just returns named arguments as `{{this}}` context and renders the component's +template. Nevertheless, it is a good example to understand the lifecycle of this +API. + +## Pooled Components + +This next example is inspired by Flexi's sustain feature to maintain a pool of +component instances. + +Given the following `ComponentDefinition`s: + +```javascript +// component-definition:list-item +{ + name: "list-item", + layout: "list-item", + manager: "pooled", + capabilities: capabilities("2.13", { + destructor: true, + viewSupport: true + }) +} +``` + +...and the following templates: + +```handlebars +{{!-- app/templates/application.hbs --}} + +{{#each key="id" as |item|}} + {{list-item item=item}} +{{/each}} +``` + +```handlebars +{{!-- app/templates/components/list-item.hbs --}} + +
  • {{item.value}}
  • +``` + +...and the following component manager: + +```javascript +// component-manager:pooled + +interface PoolEntry { + instance: Ember.Component; + expiresAt: number; +} + +class ComponentPool { + private pools = dict(); + + checkout(name: string): PoolEntry | null { + let pool = this.pools[name]; + + if (pool && pool.length) { + return pool.pop(); + } else { + return null; + } + } + + checkin(name: string, entry: PoolEntry) { + let pool = this.pools[name]; + + if (!pool) { + pool = this.pools[name] = []; + } + + entry.expiresAt = Date.now() + EXPIRATION; + + pool.push(entry); + } + + collect() { + let now = Date.now(); + + this.pools.forEach(pool => { + let i = 0; + let max = pool.length; + + while (i < max) { + let entry = pool[i]; + + if (entry.expiresAt < now) { + entry.instance.destroy(); + pool.splice(i, 1); + max--; + } else { + i++; + } + } + }); + } +} + +class PooledManager extends ComponentManager { + + private pool = new ComponentPool(); + + create({ metadata }: ComponentDefinition, args: ComponentArguments) { + let entry = pool.checkout(metadata.class); + let instance, isRecycled; + + if (entry) { + instance = entry.instance; + isRecycled = true; + } else { + instance = getOwner(this).lookup(`component:${metadata.class}`); + isRecycled = false; + entry = { instace, expiresAt: NaN }; + } + + instance.setProperties(args.named); + + if (isRecycled) { + instance.didUpdateAttrs(); + } else { + instance.didInitAttrs(); + } + + instance.didReceiveAttrs(); + + return instance; + } + + getContext({ instance }: PoolEntry): Ember.Component { + return instance; + } + + getView({ instance }): PoolEntry): Ember.Component { + return instance; + } + + update({ instance }: PoolEntry, args: ComponentArguments): void { + instance.setProperties(args.named); + instance.didUpdateAttrs(); + instance.didReceiveAttrs(); + } + + destroy(entry: PoolEntry) { + this.pool.checkin(instance); + } +} +``` + +When a `{{list-entry}}` component is rendered, the manager first check if there +are any recycled instances it could reuse. If not, it creates a new instance of +that component as usual. Either way, it calls `Ember.setProperties` on the +component instance to assign or update its properties based on the supplied +*component arguments*. + +When the component is no longer needed, instead of destroying the component, it +checks it into a pool with an expiration timestamp. If another instance of +`{{list-entry}}` is rendered before the expiration, the component instance will +be reused, otherwise, it will eventually be destroyed through a scheduled GC task. + +This examples shows how a custom component can request additional capability +(`destructor` and `viewSupport` in this case). It also shows an example of the +"state bucket" pattern – the component manager returns `entry.instance` as this +`{{this}}` context, allowing it to hide the internal `expiresAt` timestamp. It +also implements a subset of the `Ember.Component` hooks for *illustrative purpose*, +although the semantics in this **low-fi** implementation does not exactly match +the proper Ember semantics (**DO NOT** copy this code into an addon). + +# How We Teach This + +What is proposed in this RFC is a *low-level* primitive. We do not expect most +users to interact with this layer directly. Instead, they are expected to go +through additional conveniences provided by addon authors. + +For addon authors, we need to teach "components as an abstraction/encapsulation +tool" as described above. For end users, we need to make sure using third-party +component toolkits mostly "feels" like using the first-class component APIs. + +At present, the classic components APIs is still the primary, recommended path +for almost all use cases. This is the API that we should teach new users, so we +do not expect the guides need to be updated for this feature (at least not the +components section). + +For documentation purposes, each Ember.js release will only document the latest +component manager API. The documentation will also include the steps needed to +upgrade, as well as a list of new capabilities added since the last version. +Documentation for a specific version of the component manager API can be viewed +from the versioned documentation site. + +# Drawbacks + +In the long term, there is a possiblity that while we work on the new component +API, the community will create a full fleet of competing third-party component +toolkits, thereby fragmentating the Ember ecosystem. However, given the Ember +community's love for conventions, this seems unlikely. We expect this to play +out similar to the data-persistence story – there will be a primary way to do +things (Ember Data), but there are also plenty of other alternatives catering +to niche use cases that are underserved by Ember Data. + +Also, because apps can mix and match component styles, it's possible for a +library like smoke-and-mirrors or Liquid Fire to take advantage of the +enhanced functionality internally without leaking those implementation +details to applications. + +# Alternatives + +Instead of focusing on exposing enough low-level primitives to build the new +components API, we could just focus on building out the user-facing APIs +without rationalizing or exposing the underlying primitives. + +# Unresolved Questions + +There are a few minor inline open questions throughout the RFC. However, at +least two of them are worth repeating here. + +1. `ComponentDefinition` registration and resolution + + There are (at least) two distinct use cases for the APIs proposed in this + RFC. + + The first use case is for addons like LiquidFire and Flexi to implement + advanced "helpers" more efficiently and tap into low-level features that are + not exposed in the classic components API. + + For this use case, since there are only a small and finite amount of these + helpers in each addon and that addon authors are relatively advanced users, + the registration of these components does not have to be very egonormic. + + For example, we could allow addon authors to supply component definitions in + `/{app,addon}/component-definitions/foo-bar.js` and component managers in + `/{app,addon}/component-managers/my-manager.js`. + + > **Note**: we might need to restrict the definition files to "universal + JavaScript" and disallow importing arbitrary files, see the build-time + compilation discussion below. + + The second use case is to allow addons to develop an alternative component + API for their users to extend and build on – a component SDK if you will. + This would allow, among other things, the Ember core team to develop and + iterate on the new components API ("angle bracket components") as an addon. + + Since this use case is targeting regular Ember users, and creating new + components is a relatively common part of the day-to-day development + workflow, the egonormics issue is much more important. Addons that chose to + go down this route should be able to provide a developer experience similar + to using classic components today. For instance, it would be unacceptable + if developers have to create an `app/component-definitions/foo-bar.js` file + for every custom component they create in addition to the template/JS file. + + One solution to this problem is to run introspection on the component's JS + file. For example, we can require the exported value (usually the component + class) from `app/components/foo-bar.js` to contain a *definition* static + property that points to the component definition for that component. + + This solution has two major issues. + + First, one of the goals for the modules unification effort is to enable + build-time template compilation. Currently, Glimmer does a lot of runtime + triage due to syntatic ambiguities such as `{{foo-bar}}`, which could + resolve into either a helper invocation, a component invocation or a + property lookup depending on what is registered in the application. (In the + future, `` would have a similar issue since it could either be a + component invocation or just a regular custom element/web component.) + + One of the side goals/hopes with modules unification effort is to move more + of these compilation to build time by supplying a "build-time resolver" to + answer these questions for the Glimmer compiler. For that to work, the + "build-time resolver" would have to rely mostly on static information, such + as file system entries, because most Ember application files would not load + successfully without a full browser environment. (In general, to evaluate an + app file while *in the middle of* compiling the app poses many challenges – + in a lot of cases it is simply impossible.) + + By introducing custom components, the Glimmer compiler would need access to + the component definitions (specifically, the capabilities mask) in order to + emit the right compilation for the component. Therefore, if the only way to + access the component definition is to evaluate the component JS file, that + would pretty much make build-time compilation impossible. + + More importantly, the second issue with this approach is that because we are + planning to move the component's tag into the template (as opposed to having + *tagName*, *attributeBindings* and friends on the component class – see the + previous angle bracket components RFCs), we expect template-only components + (i.e. components without a JS file) to be relatively common. Therefore, in a + lot of cases, there won't even be a JS class to stash the definition on to, + making this solution a non-starter. + + Naturally, the solution seems to be that we should attach this information + to the templates rather than the JS files. There are two proposals for this + so far. The first proposal is to put this information in the filenames. For + example, `foo-bar.glimmer.hbs` or `foo-bar.lite.hbs`. The second proposal is + to put this infomration inside the body of the template as a "pragma", such + as `{{!use glimmer}}`, `{{!use lite}}` (insert syntax bikeshed here). In + both cases, addons can register a factory for the component definitions + based on a string key ("glimmer" and "lite" in the examples). + +2. Interactions with component helpers + + It is unclear how custom components should interact with the component + helper feature. + + While there are indeed a lot of open questions on how this feature should + work with angle bracket invocations, since this RFC does not introduce angle + bracket invocation syntax, those questions can be deferred until a future + RFC comes along to introduce that capability. + + However, while the `{{component}}` helper is implemented natively in Glimmer + and should have no problem handling custom components (since all components + are "custom" from Glimmer's perspective), it is unclear how it should work + with the "closure" `(component)` helper. + + Currently, the "closure component" feature is not implemented natively in + Glimmer. Instead, Glimmer currently exposes some low-level hooks to Ember's + classic components manager, which allows it to implement its "closure" + semantics. These hooks are unstable and might go away in future refactors. + + In the long run, we would like to implement this feature natively in Glimmer + so that it would "just work" with custom components. However, the current + "closure" semantics (specifically the ways it handles arguments currying) in + Ember is quite specific to the classic components API and are somewhat + counter-intuitive in some cases (the jury is still out, these issues might + simply be mistakes in the current implementation). + + It seems unlikely that these issues would be resolved in the timeframe of + this RFC (at minimum, it seems unwise to couple the two). Therefore, there + are a few options here – 1) we can disallow using component helpers with + custom components altogether for the MVP and relax that restriction in the + future; 2) we can allow only `{{component}}` helper but not `(component)`; + 3) we can allow both `{{component}}` and `(component)`, *however*, we will + disallow passing extra arguments to the `(component)` helper. + +# Follow-up RFCs + +We expect a few follow-up RFCs to introduce additional capabilities that are +not included in this minimal proposal. These RFCs can run either in parallel +to this RFC or be submitted after this initial RFC has been implemented and +tested in the wild. + +1. Expose a way to get access to the component's DOM structure, such as its + element and bounds. This RFC would also need to introduce a hook for DOM + teardown and address how event handling/delegation would work. + +2. Expose a way to get access to the [reference][1]-based APIs. This could + include the ability to customize the component's "tag" ([validator][2]). + + [1]: https://github.com/tildeio/glimmer/blob/master/guides/04-references.md + [2]: https://github.com/tildeio/glimmer/blob/master/guides/05-validators.md + +3. Expose additional features that are used to implement classic components, + `{{outlet}}` and other built-in components, such as layout customizations, + and dynamic scope access. + +4. Allow angle bracket invocation. From 7a4679c19e905a7831200c1b34138a40d7b6f91b Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Tue, 14 Mar 2017 11:00:20 -0400 Subject: [PATCH 02/13] Cleanup (#214) * Cleanup * More --- text/0000-custom-components.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 385b7d00d7..49709fc40a 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -48,7 +48,7 @@ Since those proposals, we have rewritten Ember's rendering engine from the groun up (the "Glimmer 2" project). One of the goals of the Glimmer 2 effort was to build first-class primitives for our view-layer features in the rendering engine. As part of the process, we worked to rationalize these features and to re-think -the role of components in Ember.js. This execrise has brought plenty of new ideas +the role of components in Ember.js. This exercise has brought plenty of new ideas and constraints to the table. The initial Glimmer 2 integration was completed in [November](http://emberjs.com/blog/2016/11/30/ember-2-10-released.html). @@ -330,7 +330,7 @@ class MyManager implements ComponentManager { instance.setProperties(args.named); - return { secret, instance }; + return { immutable, instance }; } // expose the component instance (but not the internal details) as @@ -383,7 +383,7 @@ class PooledManager implements ComponentManager { return instance; } - ... + //... } ``` @@ -398,7 +398,7 @@ with individual features PAYGO (pay-as-you-go). The Glimmer architecture makes it possible to break down the work of component invocation into small pieces. For example, a "template-only component" doesn't need Glimmer to invoke any -the hooks related to lifecycle management of the *component instance*. +of the hooks related to the lifecycle management of the *component instance*. Therefore, in an ideal world, they should not pay the cost associated with invoking those hooks. @@ -427,7 +427,7 @@ const { capabilities, definition } from Ember.Component; export default definition({ name: "foo-bar", capabilities: capabilities("2.13"), - ... + //... }); ``` @@ -474,7 +474,7 @@ export default definition({ someFeature: true, anotherFeature: true }), - ... + //... }); ``` From af30db2d1c211bb508a0c6320807e0674adb001f Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Tue, 14 Mar 2017 11:15:40 -0400 Subject: [PATCH 03/13] Fix destructure syntax --- text/0000-custom-components.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 49709fc40a..59f48dc583 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -422,7 +422,7 @@ specify the following: ```js import Ember from 'ember'; -const { capabilities, definition } from Ember.Component; +const { capabilities, definition } = Ember.Component; export default definition({ name: "foo-bar", @@ -466,7 +466,7 @@ not enabled by default: ```js import Ember from 'ember'; -const { capabilities, definition } from Ember.Component; +const { capabilities, definition } = Ember.Component; export default definition({ name: "foo-bar", From f587fddabeedf71d486d4a40c973954c3cfdc341 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Wed, 15 Mar 2017 13:56:04 -0700 Subject: [PATCH 04/13] Typo --- text/0000-custom-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 59f48dc583..2bf9ae81dc 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -159,7 +159,7 @@ with this component. For example, if "foo" is specified here, Ember will lookup the component manager `component-manager:foo`. This will be described in more detail in the sections below. -> **Note**: Specifying the manager as a lookup key allows the component manger +> **Note**: Specifying the manager as a lookup key allows the component manager to receive injections. The *capabilities* property specifies the optional features required by this From aca9e2db84ba6657fe20f1b7a4354b0f9d4e7108 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Thu, 16 Mar 2017 11:11:04 -0700 Subject: [PATCH 05/13] tpyo --- text/0000-custom-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 2bf9ae81dc..883033205b 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -293,7 +293,7 @@ template is revalidated, therefore any updates to the context object performed here will be reflected in the template. Ember does not provide a fine-grained change tracking system, so there is no -guarantee on how often *update* will be called. More precisely, if on of the +guarantee on how often *update* will be called. More precisely, if one of the *component arguments* have changed to a different value, *update* _will_ be called at least once; however, the reverse is not true – when *update* is called, it does not guarantee that at least one of the *component arguments* From 88706e036bb19d0d3558182f9ad101903f1853d4 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Tue, 21 Mar 2017 13:17:23 -0700 Subject: [PATCH 06/13] tyypo --- text/0000-custom-components.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 883033205b..3cdc54f5cf 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -353,7 +353,7 @@ While each invocable component needs its own `ComponentDefinition`, a single `ComponentManager` instance can create and manage many *component instances* (and this is why all of the hooks takes the *component instance* as the first parameter). In fact, all classic components in Ember today share the same -component manger. Therefore, it is important to follow the pattern of keeping +component manager. Therefore, it is important to follow the pattern of keeping your component's state in the state bucket, rather than storing it on the manager itself. @@ -481,7 +481,7 @@ export default definition({ Under the hood, this will likely be implemented as a bit mask, but component authors should treat it as an opaque value intended for internal use by the framework only. There will not be any public APIs to introspect its value (e.g. -`isEnabled()`), so component mangers should not rely on this to alter its +`isEnabled()`), so component managers should not rely on this to alter its runtime behavior. All features are additive. The default set of functionality for a particular From 786ef164058f2fd79c9e821974f7583ff629eff9 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Wed, 22 Mar 2017 12:12:59 -0700 Subject: [PATCH 07/13] oytp --- text/0000-custom-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 3cdc54f5cf..e0f953dc04 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -369,7 +369,7 @@ class PooledManager implements ComponentManager { private pools = {}; create({ metadata }: ComponentDefinition, args: ComponentArguments): Object { - let pool = this.pools[meatadata.name]; + let pool = this.pools[metadata.name]; let instance; if (pool && pool.length) { From 1cd11326a6e57d2802d2c21f5ff3f1a31ad4f2d7 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Sat, 25 Mar 2017 16:06:36 -0700 Subject: [PATCH 08/13] Fix code example --- text/0000-custom-components.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index e0f953dc04..af714360a2 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -771,6 +771,7 @@ Given the following `ComponentDefinition`s: // component-manager:pooled interface PoolEntry { + name: string; instance: Ember.Component; expiresAt: number; } @@ -788,7 +789,8 @@ class ComponentPool { } } - checkin(name: string, entry: PoolEntry) { + checkin(entry: PoolEntry) { + let { name } = entry; let pool = this.pools[name]; if (!pool) { @@ -827,16 +829,17 @@ class PooledManager extends ComponentManager { private pool = new ComponentPool(); create({ metadata }: ComponentDefinition, args: ComponentArguments) { - let entry = pool.checkout(metadata.class); + let name = metadata.class; + let entry = pool.checkout(name); let instance, isRecycled; if (entry) { instance = entry.instance; isRecycled = true; } else { - instance = getOwner(this).lookup(`component:${metadata.class}`); + instance = getOwner(this).lookup(`component:${name}`); isRecycled = false; - entry = { instace, expiresAt: NaN }; + entry = { name, instace, expiresAt: NaN }; } instance.setProperties(args.named); @@ -849,7 +852,7 @@ class PooledManager extends ComponentManager { instance.didReceiveAttrs(); - return instance; + return entry; } getContext({ instance }: PoolEntry): Ember.Component { @@ -867,7 +870,7 @@ class PooledManager extends ComponentManager { } destroy(entry: PoolEntry) { - this.pool.checkin(instance); + this.pool.checkin(entry); } } ``` From e86b38d28b8a511a98c0777e23dc51f77851bc22 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 5 Mar 2018 09:14:12 -0800 Subject: [PATCH 09/13] overhaul --- text/0000-custom-components.md | 1647 +++++++++++++++++--------------- 1 file changed, 851 insertions(+), 796 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index af714360a2..8a851d402a 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -5,22 +5,20 @@ # Summary This RFC aims to expose a _low-level primitive_ for defining _custom -components_. This API will allow addon authors to implement special-purpose -component-based APIs (such as LiquidFire's animation helpers or low-overhead -components for performance hotspots). +components_. -In the medium term, this API and expected future enhancements will enable the -Ember community to experiment with alternative component APIs outside of the -core framework, for example enabling the community to prototype "angle bracket -components" using public APIs outside of the core framework. +This API will allow addon authors to provide special-purpose component base +classes that their users can subclass from in the apps. These components are +invokable in templates just like any other Ember components (decendents of +`Ember.Component`) today. # Motivation The ability to author reusable, composable components is a core features of -the Ember.js framework. Despite being a [last-minute addition](http://emberjs.com/blog/2013/06/23/ember-1-0-rc6.html) -to Ember.js 1.0, the `Ember.Component` API has proven itself to be an extremely -powerful programming model and has aged well over time into the primary unit of -composition in the Ember view layer. +Ember.js. Despite being a [last-minute addition](http://emberjs.com/blog/2013/06/23/ember-1-0-rc6.html) +to Ember 1.0, the `Ember.Component` API and programming mode has proven itself +to be an extremely versatile tool and has aged well over time into the primary +unit of composition in Ember's view layer. That being said, the current component API (hereinafter "classic components") does have some noticeable shortcomings. Over time, classic components have also @@ -33,184 +31,269 @@ these problems via the angle bracket invocation opt-in (i.e. `` instead of `{{foo-bar ...}}`). Since the transition to the angle bracket invocation syntax was seen as a rare, -once-in-a-lifetime opportunity, it became very tempting for the Ember core team -and the wider community to debate all the problems, shortcomings and desirable -features in the classic components API and attempt to design solutions for all -of them. +once-in-a-lifetime opportunity, it became very tempting to debate every single +shortcomings and missing features in the classic components API in the process +and attempt to design solutions for all of them. While that discussion was very helpful in capturing constraints and guiding the overall direction, designing that One True API™ in the abstract turned out to -be extremely difficult and ultimately undesirable. It also went against the -Ember philosophy that framework features should be extracted from applications -and designed iteratively with feedback from real-world usage. - -Since those proposals, we have rewritten Ember's rendering engine from the ground -up (the "Glimmer 2" project). One of the goals of the Glimmer 2 effort was to -build first-class primitives for our view-layer features in the rendering engine. -As part of the process, we worked to rationalize these features and to re-think -the role of components in Ember.js. This exercise has brought plenty of new ideas -and constraints to the table. - -The initial Glimmer 2 integration was completed in [November](http://emberjs.com/blog/2016/11/30/ember-2-10-released.html). -Since Ember.js 2.10, classic components have been re-implemented on top of the -Glimmer 2 primitives, and we are very happy with the results. +be extremely difficult. It also went against our philosophy that framework +features should be extracted from applications and designed iteratively with +feedback from real-world usage. + +Since that original proposal, we have rewritten Ember's rendering engine from +the ground up (the "Glimmer 2" project). One of the goals of the Glimmer 2 +effort was to build first-class support for Ember's view-layer features into +the rendering engine. As part of the process, we worked to rationalize these +features and to re-think the role of components in Ember.js. This exercise has +brought plenty of new ideas and constraints to the table. + +The initial Glimmer 2 integration was completed in [Ember 2.10](http://emberjs.com/blog/2016/11/30/ember-2-10-released.html). +As of that version, classic components have been re-implemented using the new +primitives provided by the rendering engine, and we are very happy with the +results. This approach yielded a number of very powerful and flexible primitives: -in addition to classic components, we were able to implement the `{{mount}}`, -`{{outlet}}` and `{{render}}` helpers from Ember as "components" under the hood. +in addition to classic components, we were able to implement Ember's +`{{mount}}`, `{{outlet}}` and `{{render}}` helpers as "components" under the +hood. Based on our experience, we believe it would be beneficial to open up these new primitives to the wider community. Specifically, there are at least two clear benefits that comes to mind: -First, this would unlock new capabilities for addon authors, allowing them to -build custom components tailored to specific scenarios that are underserved by -the general-purpose component APIs (e.g. Liquid Fire's animation helpers, -low-overhead components for performance hotspots). Having an escape valve -for these scenarios also allows us to focus primarily on the mainstream use cases -when designing the new component API ("angle bracket components"). - -Second, this API and expected future enhancements will enable the Ember community -to experiment with alternative component APIs, and allow us to prototype "angle -bracket components" outside of the core framework purely on top of exposed public -APIs. +First, it provides addon authors fine-grained control over the exact behavior +and semantics of their components in cases where the general-purpose components +are a poor fit. For example, a low-overhead component designed to be used in +performance hotspot can opt-out of certain convinence features using this API. -Following the success of FastBoot and Engines, we believe the best way to design -angle bracket components is to first stablize the underlying primitives in the -core framework and then experiment with the surface API through an addon. +Second, it allows the community to experiment with and iterate on alternative +component APIs outside of the core framework. Following the success of FastBoot +and Engines, we believe the best way to design the new "Glimmer Components" API +is to first stablize the underlying primitives in the core framework and +experiment with the surface API through an addon. # Detailed design -## What is a component? +This RFC introduces the concept of *component managers*. A component manager is +an object that is responsible for coordinating the lifecycle events that occurs +when invoking, rendering and re-rendering a component. + +## Registering component managers + +Component managers are registered with the `component-manger` type in the +application's registry. Similar to services, component managers are singleton +objects (i.e. `{ singleton: true, instantiate: true }`), meaning that Ember +will create and maintain (at most) one instance of each unique component +manager for every application instance. + +To register a component manager, an addon will typically put it inside its +`app` tree: + +```js +// ember-basic-component/app/component-managers/basic.js + +import EmberObject from '@ember/object'; -In today's programming model, a component is often viewed narrowly as a device -for handling and managing user-interactions ("UI widgets"). +export default EmberObject.extend({ + // ... +}); +``` -While components are indeed very useful for building widgets, it doesn't just -stop there. In front-end development, they serve a much broader role, allowing -you to break up large templates into smaller, well-encapsulated units. +This allows the component manager to participate in the DI system – receiving +injections, using services, etc. Alternatively, component managers can also +be registered with imperative API. This could be useful for testing or opt-ing +out of the DI system. For example: -For example, you might break up a blog post into a headline, byline, body, -and a list of comments. Each comment might be further broken down into -a headline, author card and contents. +```js +// ember-basic-component/app/initializers/register-basic-component-manager.js -From Glimmer's perspective, components are analogous to functions in -other programming languages. Some components are designed to be reusable -in many contexts, but it's also perfect normal to use them to break apart -large chunks of logic. +const MANAGER = { + // ... +}; -In the most general sense, a component takes inputs (positional arguments, -named arguments, blocks, etc.), can be invoked, may render some content, -and knows how to keep its content up to date as its inputs change. From -the outside, you don't need to know how these details are managed, you -just need to know its API (i.e. what inputs it expects). +export function initialize(application) { + // We want to use a POJO here, so we are opt-ing out of instantiation + application.register('component-manager:basic', MANAGER, { instantiate: false }); +} -When looking at components expansively, it's no surprise that things that -are not usually thought of as components are implemented as components -in the template layer (input and link helpers, engines, outlets). Similarly, -we expect that this API to be useful far beyond just "UI widgets". +export default { + name: 'register-basic-component-manager', + initialize +}; +``` -## `ComponentDefinition` +## Determining which component manager to use -This RFC introduces a new type of object in Ember.js, `ComponentDefinition`, -which defines a component that can be invoked from a template. +For the purpose of this section, we will assume components with a JavaScript +file (such as `app/components/foo-bar.js` or the equivilant in "pods" and +[Module Unification](https://github.com/emberjs/rfcs/blob/master/text/0143-module-unification.md) +apps) and optionally a template file (`app/templates/components/foo-bar.hbs` +or equivilant). The example section has additional information about how this +relates to [template-only components](https://github.com/emberjs/rfcs/blob/master/text/0278-template-only-components.md). -Like classic components, a `ComponentDefinition` must be registered with a -dasherized name (with at least one dash in the name). This allows them to be -easily distinguishable from regular property lookups and HTML elements. Once -registered, the component can be invoked by name like a regular classic -component (i.e. `{{foo-bar}}`, `{{foo-bar "positional" "and" named="args"}}`, -`{{#foo-bar with or without=args}}...{{/foo-bar}}` etc). +When invoking the component `{{foo-bar ...}}`, Ember will first resolve the +component class (`component:foo-bar`, usually the `default` export from +`app/components/foo-bar.js`). Next, it will determine the appropiate component +manager to use based on the resolved component class. -> **Open Question**: How should these objects be registered? (see the last - section of this RFC) +Ember will provide a new API to assign the component manager for a component +class: -> **Open Question**: How does this interact with the `{{component}}` helper -and the `(component)` feature? (see the last section of this RFC) +```js +// my-app/app/components/foo-bar.js -A `ComponentDefinition` object should satisfy the following interface: +import EmberObject from '@ember/object'; +import { setComponentManager } from '@ember/component'; -```typescript -interface ComponentDefinition { - name: string; - layout: string; - manager: string; - capabilities: ComponentCapabilitiesMask; - metadata?: any; -} +const MyComponent = EmberObject.extend({ + // ... +}); + +setComponentManager(MyComponent, 'awesome'); + +export default MyComponent; ``` -> **Open Question**: Should we require `ComponentDefinition` to inherit from a -provided super class (`Ember.ComponentDefinition.extend({ ... }`) or otherwise -be wrapped with a function call (`Ember.Component.definition({ ... })`)? +This tells Ember to use the `awesome` manager (`component-manager:awesome`) for +the `foo-bar` component. Since the `setComponentManager` function also returns +the class, this can also be simplified as: -The *name* property should contain an identifier for this component, usually -the component's dasherized name. This is primarily used by debug tools (e.g. -Ember Inspector). +```js +// my-app/app/components/foo-bar.js -The *layout* property specifies which template should be rendered when the -component is invoked. For example, if "foo-bar" is specified, Ember will lookup -the template `template:components/foo-bar` from the resolver. +import EmberObject from '@ember/object'; +import { setComponentManager } from '@ember/component'; -> **Open Question**: How does this interact with local lookup? +export default setComponentManager(EmberObject.extend({ + // ... +}), 'awesome'); +``` -The *manager* property is a string key specifying the `ComponentManager` to use -with this component. For example, if "foo" is specified here, Ember will lookup -the component manager `component-manager:foo`. This will be described in more -detail in the sections below. +In the future, this function can also be invoked as a decorator: -> **Note**: Specifying the manager as a lookup key allows the component manager -to receive injections. +```js +// my-app/app/components/foo-bar.js -The *capabilities* property specifies the optional features required by this -component. This will be described in more detail below. +import EmberObject from '@ember/object'; +import { componentManager } from '@ember/component'; -Finally, there is an optional *metadata* property which component authors can -use to store arbitrary data. For example, it may include a *class* property to -specify which component class to use. The *metadata* property is ignored by -Ember but can be used by the `ComponentManager` to perform custom logic. +@componentManager('awesome') +export default EmberObject.extend({ + // ... +}); +``` -## `ComponentManager` +In reality, app developer would never have to write this in their apps, since +the component manager would already be assigned on a super-class provided by +the framework or an addon. The `setComponentManager` function is essentially a +low-level API designed for addon authors and not intended to be used by app +developers. -Whereas a `ComponentDefinition` describe the static property of the component, -a `ComponentManager` controls its runtime properties. +For example, the `Ember.Component` class would have the `classic` component +manager pre-assigned, therefore the following code will continue to work as +intended: -A basic `ComponentManager` satisfies the following interface: +```js +// my-app/app/components/foo-bar.js -```typescript -interface ComponentManager { - create(definition: ComponentDefinition, args: ComponentArguments): T; - getContext(instance: T): any; - update(instance: T, args: ComponentArguments): void; -} +import Component from '@ember/component'; -interface ComponentArguments { - positional: any[]; - named: Object; -} +export default Component.extend({ + // ... +}); ``` -> **Open Question**: Should we require `ComponentManager` to inherit from a -provided super class (`Ember.ComponentManager.extend({ ... }`)? +Similarly, an addon can provided the following super-class: + +```js +// ember-basic-component/addon/index.js -When Ember is about to render a component, Ember will lookup its component -manager (as described above) and call its *create* method with the component -defition and the *component arguments*. +import EmberObject from '@ember/object'; +import { componentManager } from '@ember/component'; -The *component arguments* object is a snapshot of the arguments pased into the -component. It has a *positional* and *named* property which corresponds to an -array and object (dictionary) of the current argument values. +@componentManager('basic') +export default EmberObject.extend({ + // ... +}); +``` -For example, for the following invocation: +With this, app developers can simply inherit from this in their app: + +```js +// my-app/app/components/foo-bar.js + +import TurboComponent from 'ember-basic-component'; + +export default TurboComponent.extend({ + // ... +}); +``` + +Here, the `foo-bar` component would automatically inherit the `basic` component +manager from its super-class. + +It is not advisable to override the component manager assigned by the framework +or an addon. Attempting to reassign the component manager when one is already +assinged on a super-class will be an error. If no component manager is set, it +will also result in a runtime error when invoking the component. + +## Component Lifecycle + +Back to the `{{foo-bar ...}}` example. + +Once Ember has determined the component manager to use, it will be used to +manage the component's lifecycle. + +### `createComponent` + +The first step is to create an instance of the component. Ember will invoke the +component manager's `createComponent` method: + +```javascript +// ember-basic-component/app/component-managers/basic.js + +import EmberObject from '@ember/object'; + +export default EmberObject.extend({ + createComponent(factory, args) { + return factory.create(args.named); + }, + + // ... +}); +``` + +The `createComponent` method on the component manager is responsible for taking +the component's factory and the arguments passed to the component (the `...` in +`{{foo-bar ...}}`) and return an instantiated component. + +The first argument passed to `createComponent` is the result returned from the +[`factoryFor`](https://github.com/emberjs/rfcs/blob/master/text/0150-factory-for.md) +API. It contains a `class` property, which gives you the the raw class (the +`default` export from `app/components/foo-bar.js`) and a `create` function that +can be used to instantiate the class with any registered injections, merging +them with any additional properties that are passed. + +The second argument is a snapshot of the arguments passed to the component in +the template invocation, given in the following format: + +```js +{ + positional: [ ... ], + named: { ... } +} +``` + +For example, given the following invocation: ```hbs {{blog-post (titleize post.title) post.body author=post.author excerpt=true}} ``` -Glimmer will look up the `blog-post` definition and call *create* on its -manager with the following `ComponentArguments`: +You will get the following as the second argument: -```javascript +```js { positional: [ "Rails Is Omakase", @@ -223,686 +306,635 @@ manager with the following `ComponentArguments`: } ``` -The component manager *must not* mutate the *component arguments* object (and -the inner *positional* and *named* object/array) directly as they might be -pooled or reused by the system. - -> **Note**: We should probably freeze them in debug mode. - -Based on the information in the definition and the *component arguments*, the -manager should return a *component instance* from the *create* method. From -Ember's perspective, this could be any arbitrary value – it is only used for -the component manager's internal book-keeping. In practice, this value should -store enough information to represent the internal state of the component. For -these reasons, it is often referred to as the "opaque state bucket". - -At a later time, before Ember is ready to render the template, the component -manager's *getContext* method will be called. It will receive the *component -instance* and return the context object for the template. The context binds -`{{this}}` inside the layout, which is also the root of implicit property -lookups (e.g. `{{foo}}`, `{{foo.bar}}` are equivalent to `{{this.foo}}` and -`{{this.foo.bar}}`). - -In many cases, the component manager can simply return the *component instance* -from this hook: - -```typescript -class MyManager implements ComponentManager { - create(definition: ComponentDefinition, args: ComponentArguments): MyComponent { - ... - } +The arguments object should not be mutated (e.g. `args.positional.pop()` is no +good). In development mode, it might be sealed/frozen to help prevent these +kind of mistakes. - getContext(instance: MyComponent): MyComponent { - return instance; - } +### `getContext` - update(instance: MyComponent, args: ComponentArguments) { - ... - } -} -``` +Once the component instance has been created, the next step is for Ember to +determine the `this` context to use when rendering the component's template by +calling the component manager's `getContext` method: -However, they could also choose derive a different value from the state bucket. -This pattern allows the component manager to hide internal metadata: +```js +// ember-basic-component/app/component-managers/basic.js -```typescript -interface MyStateBucket { - instance: MyComponent; - secret: any; -} +import EmberObject from '@ember/object'; -class MyManager implements ComponentManager { - create(definition: ComponentDefinition, args: ComponentArguments): MyStateBucket { - ... - } +export default EmberObject.extend({ + createComponent(factory, args) { + return factory.create(args.named); + }, - getContext({ instance }: MyStateBucket): MyComponent { - return instance; - } + getContext(component) { + return component; + }, - update({ instance, secret }: MyStateBucket, args: ComponentArguments) { - ... - } -} + // ... +}); ``` -Finally, when any of the *component arguments* have changed, Ember will call -the *update* method on the manager, passing the *component instance* and a -snapshot of the current *component arguments* value. This happens before the -template is revalidated, therefore any updates to the context object performed -here will be reflected in the template. +The `getContext` method gets passed the component instance returned from +`createComponent` and should return the object that `{{this}}` should refer to +in the component's template, as well as for any "fallback" property lookups +such as `{{foo}}` where `foo` is neither a local variable or a helper (which +resolves to `{{this.foo}}` where `this` is here is the object returned by +`getContext`). -Ember does not provide a fine-grained change tracking system, so there is no -guarantee on how often *update* will be called. More precisely, if one of the -*component arguments* have changed to a different value, *update* _will_ be -called at least once; however, the reverse is not true – when *update* is -called, it does not guarantee that at least one of the *component arguments* -will have a different value. +Typically, this method can simpliy return the component instance, as shown in +the example above. The reason this exists as a separate method is to enable the +so-called "state bucket" pattern which allows addon authors to attach extra +book-keeping metadata to the component: -For example, given the following invocation: +```js +// ember-basic-component/app/component-managers/basic.js -```hbs -{{my-component unit=(pluralize "cat" count=cats.count)}} +import EmberObject from '@ember/object'; + +export default EmberObject.extend({ + createComponent(factory, args) { + let metadata = { ... }; + let instance = factory.create(args.named); + return { metadata, instance, ... }; + }, + + getContext(bucket) { + return bucket.instance; + }, + + // ... +}); ``` -If `cats.count` changes from 3 to 4, the *pluralize* helper will return exactly -the same value (the string `"cats"`), therefore from the manager's perspective, -the *component arguments* have not changed. However, in today's implementation, -the *update* method will still be called in this case. Over time, we intend for -internal changes in the implementation to cause this method to be called less -often, and other changes may cause it to be called more often. As a result, -component managers should not rely on this detail. +Since the "state bucket", not the "context", is passed back to other hooks on +the component manager, this allows the component manager to access the extra +metadata but otherwise hide them from the app developers. -Here is a simple example that puts all of these pieces together: +We will see an example that uses this pattern in a later section. -```typescript -interface MyStateBucket { - immutable: boolean; - instance: Object; -} +At this point, Ember will have gathered all the information it needs to render +the component's template, which will be rendered with ["Outer HTML" semantics](https://github.com/emberjs/rfcs/blob/master/text/0278-template-only-components.md). -class MyManager implements ComponentManager { - // on initial invocation, turn the arguments into a per-instance state bucket - // that contains the component instance and some other internal details - create(definition: ComponentDefinition, args: ComponentArguments): MyStateBucket { - let immutable = Math.random() > 0.5; - let instance = getOwner(this).lookup(`component:${definition.metadata.class}`); +In other words, the content of the template will be rendered as-is, without a +wrapper element (e.g. `
    ...
    `), +except for subclasses of `Ember.Component`, which will retain the current +legacy behavior (the internal `classic` manager uses private capabilities to +achieve that). - instance.setProperties(args.named); +This API does not currently provide any way to fine-tune the rendering behavior +(such as dynamically changing the component's template) besides `getContext`, +but future iterations may introduce extra capabilities. - return { immutable, instance }; - } +### `updateComponent` - // expose the component instance (but not the internal details) as - // {{this}} in the template - getContext({ instance }: MyStateBucket): any { - return instance; - } +When it comes time to re-render a component's template (usually because an +argument has changed), Ember will call the manager's `updateComponent` method +to give the manager an opportunity to reflect those changes on the component +instance, before performing the re-render: - // when update is called, update the properties on the component with the - // new values of the named arguments. - update({ immutable, instance }: MyStateBucket, args: ComponentArguments) { - if (!immutable) { - instance.setProperties(args.named); - } - } -} -``` +```js +// ember-basic-component/app/component-managers/basic.js -While each invocable component needs its own `ComponentDefinition`, a single -`ComponentManager` instance can create and manage many *component instances* -(and this is why all of the hooks takes the *component instance* as the first -parameter). In fact, all classic components in Ember today share the same -component manager. Therefore, it is important to follow the pattern of keeping -your component's state in the state bucket, rather than storing it on the -manager itself. +import EmberObject from '@ember/object'; -That being said, in some rare cases, it might make sense to store some shared -states on the manager. For example, a component manager might want to maintain -a pool of *component instances* and reuse them when possible (e.g. `{{sustain}}` -in flexi). In this case, it would make sense to store the components pool on -the manager: +export default EmberObject.extend({ + createComponent(factory, args) { + return factory.create(args.named); + }, -```typescript -class PooledManager implements ComponentManager { + getContext(component) { + return component; + }, - private pools = {}; + updateComponent(component, args) { + component.setProperties(args.named); + }, - create({ metadata }: ComponentDefinition, args: ComponentArguments): Object { - let pool = this.pools[metadata.name]; - let instance; + // ... +}); +``` - if (pool && pool.length) { - instance = pool.pop(); - } else { - instance = getOwner(this).lookup(`component:${metadata.class}`); - } +The first argument passed to this method is the component instance returned by +`createComponent`. As mentioned above, using the "state bucket" pattern will +allow this hook to access the extra metadata: - instance.setProperties(args.named); +```js +// ember-basic-component/app/component-managers/basic.js - return instance; - } +import EmberObject from '@ember/object'; - //... +export default EmberObject.extend({ + createComponent(factory, args) { + let metadata = { ... }; + let instance = factory.create(args.named); + return { metadata, instance, ... }; + }, -} -``` + getContext(bucket) { + return bucket.instance; + }, -## `ComponentCapabilitiesMask` + updateComponent(bucket, args) { + let { metadata, instance } = bucket; + // do things with metadata + instance.setProperties(args.named); + }, -The *capabilities* property on a `ComponentDefinition` describes the optional -features required by this component. There are several reasons for this design. + // ... +}); +``` -First of all, performance-wise, this allows us to make the costs associated -with individual features PAYGO (pay-as-you-go). The Glimmer architecture makes -it possible to break down the work of component invocation into small pieces. +The second argument is a snapshot of the updated arguments, passed with the +same format as in `createComponent`. Note that there is no guarentee that +anything in the arguments object has _actually_ changed when this method is +called. For example, given: -For example, a "template-only component" doesn't need Glimmer to invoke any -of the hooks related to the lifecycle management of the *component instance*. -Therefore, in an ideal world, they should not pay the cost associated with -invoking those hooks. +```hbs +{{blog-post title=(uppercase post.title) ...}} +``` -To accomplish this, we designed the component manager to only expose a minimal -set of features by default and allow individual components to opt-in to additional -features as needed. The capabilities mask is the mechanism for components to -signal that. +Imagine if `post.title` changed from `fOo BaR` to `FoO bAr`. Since the value +is passed through the `uppercase` helper, the component will see `FOO BAR` in +both cases. -Second, it allows us to introduce new features and make improvements to this -API without breaking existing code. In addition to enumerating the required -features, the `ComponentCapabilitiesMask` also encodes the version of Ember.js -the `ComponentDefinition`/`ComponentManager` was developed for. This makes it -possible to, say, make angle bracket invocation the default for new components -but preserve the current default ("curly" invocation) for existing components -that were targeting and older API revision. +Generally speaking, Ember does not provide any guarentee on how it determine +whether components need to be re-rendered, and the semantics may vary between +releases – i.e. this method may be called more or less often as the internals +changes. The _only_ guarentee is that if something _has_ changed, this method +will definitely be called. -Ember.js will provide a function to construct a `ComponentCapabilitiesMask`. -Since this is a required property, at minimum, a component definition would -specify the following: +If it is important to your component's programming model to _only_ notify the +component when there are actual changes, the manager is responsible for doing +the extra book-keeping. + +For example: ```js -import Ember from 'ember'; +// ember-basic-component/app/component-managers/basic.js + +import EmberObject from '@ember/object'; + +export default EmberObject.extend({ + createComponent(factory, args) { + return { + args: args.named, + instance: factory.create(args.named) + }; + }, + + getContext(bucket) { + return bucket.instance; + }, + + updateComponent(bucket, args) { + let instance = bucket.instance; + let oldArgs = bucket.args; + let newArgs = args.named; + let changed = false; + + // Since the arguments are coming from the template invocation, you can + // generally assume that they have exactly the same keys. However, future + // additions such as "splat arguments" in the template layer might change + // that assumption. + for (let key of Object.keys(oldArgs)) { + let oldValue = oldArgs[key]; + let newValue = newArgs[key]; + + if (oldValue !== newValue) { + instance.argumentWillChange(key, oldValue, newValue); + instance.set(key, newValue); + instance.argumentDidChange(key, oldValue, newValue); + } + } -const { capabilities, definition } = Ember.Component; + bucket.args = newArgs; + }, -export default definition({ - name: "foo-bar", - capabilities: capabilities("2.13"), - //... + // ... }); ``` -The first argument to the function is a string specifying the Ember.js version -(up to the minor version) this component is targeting. As mentioned above, this -allows us to gracefully handle API changes between versions. This is important -because the raw, low-level APIs in Glimmer are still highly unstable. - -Changes to the Glimmer APIs at this layer can significantly improve the overall -performance of the component system by changing its internal implementation. -Even though the surface API is still under active development, we would like to -start exposing the stable parts of this system to Ember addon authors as soon -as possible. - -The version identifier in the capabilities mask acts as a safety net. It allows -Ember to absorb the churn and avoid breaking existing code. - -For example, the API proposed in this RFC supplies **reified** *component -arguments* to the component manager's *create* and *update* methods, i.e. a -"snapshot" of the current *component arguments* values, which imposes some -unnecessary cost. Eventually, we would like to address this problem by moving -to a [reference](https://github.com/tildeio/glimmer/blob/master/guides/04-references.md)-based -API. - -Imagine that, by the time Ember 2.14 comes around, we were able to stablize the -more performant reference-based APIs. Instead of the reified *component -arguments* snapshot, new components targeting Ember 2.14 (i.e. `capabilities("2.14")`) -will receive the reference-based *component arguments* objects instead. -However, existing components that were written before Ember 2.14 (e.g. -`capabilities("2.13")`) will continue to receive the reified *component -arguments* for backwards compatability. - -The second argument allows components to opt-in to additional features that are -not enabled by default: +This example also shows when the "state bucket" pattern could be useful. + +The return value of the `updateComponent` is ignored. + +After calling the `updateComponent` method, Ember will update the component's +template to reflect any changes. + +## Capabilities + +In addition to the methods specified above, component managers are required to +have a `capabilities` property: ```js -import Ember from 'ember'; +// ember-basic-component/app/component-managers/basic.js -const { capabilities, definition } = Ember.Component; +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; -export default definition({ - name: "foo-bar", - capabilities: capabilities("2.13", { - someFeature: true, - anotherFeature: true - }), - //... +export default EmberObject.extend({ + capabilities: capabilities('3.2'), + + createComponent(factory, args) { + return factory.create(args.named); + }, + + getContext(component) { + return component; + }, + + updateComponent(component, args) { + component.setProperties(args.named); + } }); ``` -Under the hood, this will likely be implemented as a bit mask, but component -authors should treat it as an opaque value intended for internal use by the -framework only. There will not be any public APIs to introspect its value (e.g. -`isEnabled()`), so component managers should not rely on this to alter its -runtime behavior. +This property must be set to the result of calling the `capabilities` function +provided by Ember. -All features are additive. The default set of functionality for a particular -version is always the most lightweight component available at the time. +The first, mandatory, argument to the `capabilities` function is the component +manager API, which is denoted in the `${major}.${minor}` format, matching the +minimum Ember version this manager is targeting. -### Optional Features +This allows Ember to introduce new capabilities and make improvements to this +API without breaking existing code. -Below is list of optional features supported in the initial implementation. -This list is intentionally kept minimal to limit the scope of this RFC. +Here is a hypothical scenario for such a change: -Additional features (e.g. angle bracket invocation, references-based APIs, -providing access to the component element, event handling/delegation) should be -proposed in their own RFCs (they can even be proposed _in parallel_ to this -RFC) which allows for a more focused discussions around those topics. +1. Ember 3.2 implemented and shipped the component manager API as described in + this API. -* `asyncLifecycleHooks` +2. The `ember-basic-component` addon releases its first version with the + component manager shown above (notably, it declared `capabilities('3.2')`). - When this feature is enabled, the `ComponentManager` should implement this - extended interface: +3. In Ember 3.5, we determined that constructing the arguments object passed to + the hooks is a major performance bottleneck, and changes the API to pass a + "proxy" object with getter methods instead (e.g. `args.getPositional(0)` and + `args.getNamed('foo')`). - ```typescript - interface WithAsyncHooks extends ComponentManager { - didCreate(instance: T): void; - didUpdate(instance: T): void; - } - ``` + However, since Ember sees that the `basic` component manager is written to + target the `3.2` API version, it will retain the old behavior and passes the + old (more expensive) "reified" arguments object instead, to avoid breakage. - These methods will be called with the *component instance* ("state bucket") - *after the rendering process is complete*. This would be the right time to - invoke any user callbacks, such as *didInsertElement* and *didRender* in the - classic components API. +4. The `ember-basic-component` addon author would like to take advantage of + this performance optimization, so it updates its component manager code to + work with the arguments proxy and changes its capabilities declaration to + `capabilities('3.5')` in a major release. - It is guaranteed that the async callbacks will the invoked if and only if the - synchronous APIs were invoked during rendering. For example, if *update* is - called on a *component instance* during rendering, it is guaranteed *didUpdate* - will be called. Conversely, if *didUpdate* is called, it is guaranteed that - that *component instance* was updated during rendering (per the semantics of - the *update* method described above). +This system allows us to rapidly improve the API and take advantage of the +underlying rendering engine featuers as soon as they become available. - > **Open Question**: Should we provide ordering guarantee? +The second, optional, argument to the `capabilities` is an object enumerating +the optional features requested by the component manager. -* `destructor` +In the hypothical example above, while the "reified" arguments objects may be +a little slower, they are certainly easier to work with, and the performance +may not matter to but the most performance critical components. A component +manager written for Ember 3.5 (again, only hypothically) and above would be +able to explicitly opt back into the old behavior like so: - When this feature is enabled, the `ComponentManager` should implement this - extended interface: +```js +// ember-basic-component/app/component-managers/basic.js - ```typescript - interface WithDestructor extends ComponentManager { - destroy(instance: T): void; - } - ``` +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; - The *destroy* method will be called with the *component instance* ("state - bucket") when the component is no longer needed. This is intended for - performing object-model level cleanup. It might also be useful for niche - scenarios such as recycling *component instances* in the "component pool" - example mentioned above. +export default EmberObject.extend({ + capabilities: capabilities('3.5', { + reifyArguments: true + }), - Because this RFC does not provide ways access to the component DOM tree, the - timing relative to DOM teardown is undefined (i.e. whether this is called - before or after the component's DOM tree is removed from the document). - Therefore, this hook is **not** suitable for invoking DOM cleanup hooks such - as *willDestroyElement* in the classic components API. We expect a subsequent - RFC addressing DOM-related functionalities to clearify this issues (and - most-likely provide a specialized hook for that purpose). + // ... +}); +``` - Further, the exact timing for the call is also undefined. For example, the - calls from several render loops might be batched together and deferred into a - browser idle callback. +In general, we will aim to have the defaults set to as bare-bone as possible, +and allow the component managers to opt into the features they need in a PAYGO +(pay-as-you-go) manner, which aligns with the Glimmer VM philosophy. As the +rendering engine evolves, more and more feature will become optional. - > **Open Question**: Should we provide ordering guarantee? +## Optional Capabilities -* `viewSupport` +The following optionally capabilities will be available with the first version +of the component manager API. We expect future RFCs to propose additional +capabilities within the framework provided by this initial RFC. - When this feature is enabled, the `ComponentManager` should implement this - extended interface: +### Async Lifecycle Callbacks - ```typescript - interface WithViewSupport extends ComponentManager { - getView(instance: T): Object; - } - ``` - - The *getView* method will be called with the *component instance* ("state - bucket") after the component has been created but before any children - components in its layout (or yielded blocks) are created. The manager should - return a view object corresponding to the *component instance* from this hook. - A lot of times, this will just be the *component instance* itself, but this - indirection allows you to hide (or intentionally expose) additional metadata. - - Ember will install a GUID on the returned object using `Ember.guidFor` and - add the view to the view registry with the GUID. Currently, `Ember.guidFor` - adds an internal property on the object to store the GUID, therefore the view - cannot be frozen or sealed. This restriction might change in the future as - we move to a `WeakMap`-based internal implementation. - - The view will also become visible to other components as a parent/child view. - If the parent view is an `Ember.Component`, it will see your component in its - *childViews* array. Similarly, when rendering other `Ember.Component` inside - your comoponent's layout (or yielded blocks), they will see your component as - their *parentView*. - - > **Open Question**: Will this break existing code? - - Note that these only applies to components that requests the *viewSupport* - capability. Components that do not have *viewSupport* enabled will essentially - be invisible in the Ember view hierarchy – they will not be added to the view - registry and the parent/child view tree will essentially "jump through" these - virtual views. At present, those components will also be invisible to debug - tools like the Ember Inspector, but subsequent RFCs may change that. - - Components that are conceptually "advanced flow-control syntax", such as - LiquidFire's *liquid-if*, most likely want to remain "virtual". On the other - hand, general-purpose component toolkits probably want to participate in the - view hierarchy like `Ember.Component`s do. +When the `asyncLifecycleCallbacks` capability is set to `true`, the component +manager is expected to implement two additional methods: `didCreateComponent` +and `didUpdateComponent`. -# Examples +`didCreateComponent` will be called after the component has been rendered the +first time, after the whole top-down rendering process is completed. Similarly, +`didUpdateComponent` will be called after the component has been updated, after +the whole top-down rendering process is completed. This would be the right time +to invoke any user callbacks, such as `didInsertElement` and `didRender` in the +classic components API. -> **Note**: For now, these examples side-steps the registration/resolution -> problem (see the "Unresolved Questions" section) by specifying the outcome -> of a particular container lookup (using the `type:name` convention) without -> addressing *how* they get in there in the first place. +These methods will be called with the component instance (the "state bucket" +returned by `createComponent`) as the only argument. The return value is +ignored. -## Isolated Partials +These callbacks are called if and only if their synchronous APIs were invoked +during rendering. For example, if `updateComponent` was called on during +rendering (and it completed without errors), `didUpdateComponent` will always +be called. Conversely, if `didUpdateComponent` is called, you can infer that +the `updateComponent` was called on the same component instance during +rendering. -This example implements a very simple component API that acts more or less like -a partial in Ember. However, unlike partials, it will only have access to named -arguments that are explicitly passed in, instead of inheriting the caller's -scope. +This API provides no guarentee about ordering with respect to siblings or +parent-child relationships. -Given the following `ComponentDefinition`s: +### Destructors -```javascript -// component-definition:site-header -{ - name: "site-header", - layout: "site-header", - manager: "isolated-partials", - capabilities: capabilities("2.13") -} -``` +When the `destructor` capability is set to `true`, the component manager is +expected to implement an additional method: `destroyComponent`. -```javascript -// component-definition:site-footer -{ - name: "site-footer", - layout: "site-footer", - manager: "isolated-partials", - capabilities: capabilities("2.13") -} -``` +`destroyComponent` will be called when the component is no longer needed. This +is intended for performing object-model level cleanup. -```javascript -// component-definition:contact-us -{ - name: "contact-us", - layout: "contact-us", - manager: "isolated-partials", - capabilities: capabilities("2.13") -} -``` +Because this RFC does not provide ways to access or observe the component's DOM +tree, the timing relative to DOM teardown is undefined (i.e. whether this is +called before or after the component's DOM tree is removed from the document). -...and the following templates: +Therefore, this hook is not suitable for invoking user callbacks intended for +performing DOM cleanup, such as `willDestroyElement` in the classic components +API. We expect a subsequent RFC addressing DOM-related functionalities to +clearify this issues or provide another specialized method for that purpose. -```handlebars -{{!-- app/templates/application.hbs --}} +Similar to the other async lifecycle callbacks, this API provides no guarentee +about ordering with respect to siblings or parent-child relationships. Further, +the exact timing of the calls are also undefined. For example, the calls from +several render loops might be batched together and deferred into a browser idle +callback. -{{site-header}} +## Inspector Support -{{outlet}} +When the `inspectorSupport` capability is set to `true`, the component manager +is expected to implement an additional method: `inspectComponent`. -{{site-footer copyrightYear="2017" company=model}} -``` +`destroyComponent` will be called when the component is no longer needed. This +is intended for performing object-model level cleanup. -```handlebars -{{!-- app/templates/components/site-header.hbs --}} +Because this RFC does not provide ways to access or observe the component's DOM +tree, the timing relative to DOM teardown is undefined (i.e. whether this is +called before or after the component's DOM tree is removed from the document). -
    -

    Welcome

    -
    -``` +Therefore, this hook is not suitable for invoking user callbacks intended for +performing DOM cleanup, such as `willDestroyElement` in the classic components +API. We expect a subsequent RFC addressing DOM-related functionalities to +clearify this issues or provide another specialized method for that purpose. -```handlebars -{{!-- app/templates/components/site-footer.hbs --}} +Similar to the other async lifecycle callbacks, this API provides no guarentee +about ordering with respect to siblings or parent-child relationships. Further, +the exact timing of the calls are also undefined. For example, the calls from +several render loops might be batched together and deferred into a browser idle +callback. -
    - © {{copyrightYear}} {{company.name}}. +# Examples - {{contact-us tel=company.tel address=company.address}} -
    -``` +## Basic Component Manager -```handlebars -{{!-- app/templates/components/contact-us.hbs --}} +Here is the simpliest end-to-end component manager example that uses a plain +`Ember.Object` super-class (as opposed to `Ember.Component`) with "Outer HTML" +semantics: -
    -

    Contact Us

    - {{tel}} -
    {{address}}
    -
    -``` +```js +// ember-basic-component/app/component-managers/basic.js -...and the following component manager: +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; -```javascript -// component-manager:isolated-partials +export default EmberObject.extend({ + capabilities: capabilities('3.2', { + destructor: true + }), -class IsolatedPartialsManager extends ComponentManager { - create(_definition: ComponentDefinition, args: ComponentArguments) { - return { ...args.named }; - } + createComponent(factory, args) { + return factory.create(args.named); + }, - getContext(instance: Object): Object { - return instance; - } + getContext(component) { + return component; + }, - update(instance: Object, args: ComponentArguments): void { - Object.assign(instance, args.named); + updateComponent(component, args) { + component.setProperties(args.named); + }, + + destroyComponent(component) { + component.destroy(); } -} +}); +``` + +```js +// ember-basic-component/addon/index.js + +import EmberObject from '@ember/object'; +import { setComponentManager } from '@ember/component'; + +export default setComponentManager(EmberObject.extend(), 'basic'); ``` -...it would render something like this: +### Usage -```html -
    -

    Welcome

    -
    +```js +// my-app/app/components/x-counter.js -... +import BasicCompoment from 'ember-basic-component'; -
    - © 2017 ACME Inc. +export default BasicCompoment.extend({ + init() { + this.count = 0; + }, -
    -

    Contact Us

    - 1-800-ACME-INC -
    100 Absolutely No Way, Portland, OR 98765
    -
    -
    + down() { + this.decrementProperty('count'); + }, + + up() { + this.incrementProperty('count'); + } +}); ``` -This is probably most basic `ComponentManager` one could write. It essentially -just returns named arguments as `{{this}}` context and renders the component's -template. Nevertheless, it is a good example to understand the lifecycle of this -API. +```hbs +{{!-- my-app/app/templates/components/x-counter.hbs --}} + +
    + + {{this.count}} + +
    +``` -## Pooled Components +## Template-only Components -This next example is inspired by Flexi's sustain feature to maintain a pool of -component instances. +This example implements a kind of component similar to what was proposed in the +[template-only components](https://github.com/emberjs/rfcs/blob/master/text/0278-template-only-components.md) +RFC. -Given the following `ComponentDefinition`s: +Since the custom components API proposed in this RFC requires a JavaScript +files, we cannot implement true "template-only" components. We will need to +create a component JS file to export a dummy value, for the sole purpose of +indicating the component manager we want to use. -```javascript -// component-definition:list-item -{ - name: "list-item", - layout: "list-item", - manager: "pooled", - capabilities: capabilities("2.13", { - destructor: true, - viewSupport: true - }) -} -``` +In practice, there is no need for an addon to implement this API, since it is +essentially re-implementing what the "template-only-glimmer-components" +optional feature does. Nevertheless, this example is useful for illustrative +purposes. -...and the following templates: +```js +// ember-template-only-component/app/component-managers/template-only.js -```handlebars -{{!-- app/templates/application.hbs --}} +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; -{{#each key="id" as |item|}} - {{list-item item=item}} -{{/each}} -``` +export default EmberObject.extend({ + capabilities: capabilities('3.2'), -```handlebars -{{!-- app/templates/components/list-item.hbs --}} + createComponent() { + return null + }, -
  • {{item.value}}
  • + getContext() { + return null; + }, + + updateComponent() { + return; + } +}); ``` -...and the following component manager: +```js +// ember-template-only-component/addon/index.js -```javascript -// component-manager:pooled +import { setComponentManager } from '@ember/component'; -interface PoolEntry { - name: string; - instance: Ember.Component; - expiresAt: number; -} +// Our `createComponent` method does not actually do anything with the factory, +// so we don't even need to export a class here, `{}` would work just fine. +export default setComponentManager({}, 'template-only'); +``` -class ComponentPool { - private pools = dict(); +### Usage - checkout(name: string): PoolEntry | null { - let pool = this.pools[name]; +```js +// my-app/app/components/hello-world.js - if (pool && pool.length) { - return pool.pop(); - } else { - return null; - } - } +import TemplateOnlyComponent from 'ember-template-only-component'; - checkin(entry: PoolEntry) { - let { name } = entry; - let pool = this.pools[name]; +export default TemplateOnlyComponent; +``` - if (!pool) { - pool = this.pools[name] = []; - } +```hbs +Hello world! I have no backing class! {{this}} would be null. +``` - entry.expiresAt = Date.now() + EXPIRATION; +## Recycling Components - pool.push(entry); - } +This example implements an API which maintain a pool of recycled component +instances to avoid allocation costs, similar to Flex's "sustain" feature. - collect() { - let now = Date.now(); +This example also make use of the "state bucket" pattern. - this.pools.forEach(pool => { - let i = 0; - let max = pool.length; +```js +// ember-component-pool/app/component-managers/pooled.js - while (i < max) { - let entry = pool[i]; +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; - if (entry.expiresAt < now) { - entry.instance.destroy(); - pool.splice(i, 1); - max--; - } else { - i++; - } - } - }); - } -} +// How many instances to keep (per type/factory) +const LIMIT = 10; -class PooledManager extends ComponentManager { +export default EmberObject.extend({ + capabilities: capabilities('3.2', { + destructor: true + }), - private pool = new ComponentPool(); + init() { + this.pool = new Map(); + }, - create({ metadata }: ComponentDefinition, args: ComponentArguments) { - let name = metadata.class; - let entry = pool.checkout(name); - let instance, isRecycled; + createComponent(factory, args) { + let instances = this.pool.get(factory); + let instance; - if (entry) { - instance = entry.instance; - isRecycled = true; + if (instances && instances.length > 0) { + instance = instances.pop(); + instance.setProperties(args.named); } else { - instance = getOwner(this).lookup(`component:${name}`); - isRecycled = false; - entry = { name, instace, expiresAt: NaN }; + instance = factroy.create(args.named); } + // We need to remember which factory does the instance belong to so we can + // check it back into the pool later. + return { factory, instance }; + }, + + getContext({ instance }) { + return instance; + }, + + updateComponent({ instance }, args) { instance.setProperties(args.named); + }, + + destroyComponent({ factory, instance }) { + let instances; - if (isRecycled) { - instance.didUpdateAttrs(); + if (this.pool.has(factory)) { + instances = this.pool.get(factory); } else { - instance.didInitAttrs(); + this.pool.set(factory, instances = []); } - instance.didReceiveAttrs(); + if (instances.length >= LIMIT) { + instance.destroy(); + } else { + // User hook to reset any state + instance.willRecycle(); + instances.push(instance); + } + }, + + // This is the `Ember.Object` lifecycle method, called when the component + // manager instance _itself_ is being destroyed, not to be confused with + // `destroyComponent` + willDestroy() { + for (let instances of this.pool.values()) { + instances.forEach(instance => instance.destroy()); + } - return entry; + this.pool.clear(); } +}); +``` - getContext({ instance }: PoolEntry): Ember.Component { - return instance; - } +```js +// ember-component-pool/addon/index.js - getView({ instance }): PoolEntry): Ember.Component { - return instance; - } +import EmberObject from '@ember/object'; +import { setComponentManager } from '@ember/component'; - update({ instance }: PoolEntry, args: ComponentArguments): void { - instance.setProperties(args.named); - instance.didUpdateAttrs(); - instance.didReceiveAttrs(); - } +function NOOP() {} - destroy(entry: PoolEntry) { - this.pool.checkin(entry); - } -} -``` +export default setComponentManager(EmberObject.extend({ + // Override this to implement reset any state on the instance + willRecycle(): NOOP, -When a `{{list-entry}}` component is rendered, the manager first check if there -are any recycled instances it could reuse. If not, it creates a new instance of -that component as usual. Either way, it calls `Ember.setProperties` on the -component instance to assign or update its properties based on the supplied -*component arguments*. - -When the component is no longer needed, instead of destroying the component, it -checks it into a pool with an expiration timestamp. If another instance of -`{{list-entry}}` is rendered before the expiration, the component instance will -be reused, otherwise, it will eventually be destroyed through a scheduled GC task. - -This examples shows how a custom component can request additional capability -(`destructor` and `viewSupport` in this case). It also shows an example of the -"state bucket" pattern – the component manager returns `entry.instance` as this -`{{this}}` context, allowing it to hide the internal `expiresAt` timestamp. It -also implements a subset of the `Ember.Component` hooks for *illustrative purpose*, -although the semantics in this **low-fi** implementation does not exactly match -the proper Ember semantics (**DO NOT** copy this code into an addon). + // ... +}), 'pooled'); +``` # How We Teach This What is proposed in this RFC is a *low-level* primitive. We do not expect most -users to interact with this layer directly. Instead, they are expected to go -through additional conveniences provided by addon authors. - -For addon authors, we need to teach "components as an abstraction/encapsulation -tool" as described above. For end users, we need to make sure using third-party -component toolkits mostly "feels" like using the first-class component APIs. +users to interact with this layer directly. Instead, most users will simply +benefit from this feature by subclassing these special components provided by +addons. At present, the classic components APIs is still the primary, recommended path for almost all use cases. This is the API that we should teach new users, so we @@ -917,13 +949,12 @@ from the versioned documentation site. # Drawbacks -In the long term, there is a possiblity that while we work on the new component -API, the community will create a full fleet of competing third-party component -toolkits, thereby fragmentating the Ember ecosystem. However, given the Ember -community's love for conventions, this seems unlikely. We expect this to play -out similar to the data-persistence story – there will be a primary way to do -things (Ember Data), but there are also plenty of other alternatives catering -to niche use cases that are underserved by Ember Data. +In the long term, there is a risk of fragmentating the Ember ecosystem with +many competing component APIs. However, given the Ember community's strong +desire for conventions, this seems unlikely. We expect this to play out similar +to the data-persistence story – there will be a primary way to do things (Ember +Data), but there are also plenty of other alternatives catering to niche use +cases that are underserved by Ember Data. Also, because apps can mix and match component styles, it's possible for a library like smoke-and-mirrors or Liquid Fire to take advantage of the @@ -936,128 +967,9 @@ Instead of focusing on exposing enough low-level primitives to build the new components API, we could just focus on building out the user-facing APIs without rationalizing or exposing the underlying primitives. -# Unresolved Questions - -There are a few minor inline open questions throughout the RFC. However, at -least two of them are worth repeating here. - -1. `ComponentDefinition` registration and resolution - - There are (at least) two distinct use cases for the APIs proposed in this - RFC. - - The first use case is for addons like LiquidFire and Flexi to implement - advanced "helpers" more efficiently and tap into low-level features that are - not exposed in the classic components API. - - For this use case, since there are only a small and finite amount of these - helpers in each addon and that addon authors are relatively advanced users, - the registration of these components does not have to be very egonormic. - - For example, we could allow addon authors to supply component definitions in - `/{app,addon}/component-definitions/foo-bar.js` and component managers in - `/{app,addon}/component-managers/my-manager.js`. - - > **Note**: we might need to restrict the definition files to "universal - JavaScript" and disallow importing arbitrary files, see the build-time - compilation discussion below. - - The second use case is to allow addons to develop an alternative component - API for their users to extend and build on – a component SDK if you will. - This would allow, among other things, the Ember core team to develop and - iterate on the new components API ("angle bracket components") as an addon. - - Since this use case is targeting regular Ember users, and creating new - components is a relatively common part of the day-to-day development - workflow, the egonormics issue is much more important. Addons that chose to - go down this route should be able to provide a developer experience similar - to using classic components today. For instance, it would be unacceptable - if developers have to create an `app/component-definitions/foo-bar.js` file - for every custom component they create in addition to the template/JS file. - - One solution to this problem is to run introspection on the component's JS - file. For example, we can require the exported value (usually the component - class) from `app/components/foo-bar.js` to contain a *definition* static - property that points to the component definition for that component. - - This solution has two major issues. - - First, one of the goals for the modules unification effort is to enable - build-time template compilation. Currently, Glimmer does a lot of runtime - triage due to syntatic ambiguities such as `{{foo-bar}}`, which could - resolve into either a helper invocation, a component invocation or a - property lookup depending on what is registered in the application. (In the - future, `` would have a similar issue since it could either be a - component invocation or just a regular custom element/web component.) - - One of the side goals/hopes with modules unification effort is to move more - of these compilation to build time by supplying a "build-time resolver" to - answer these questions for the Glimmer compiler. For that to work, the - "build-time resolver" would have to rely mostly on static information, such - as file system entries, because most Ember application files would not load - successfully without a full browser environment. (In general, to evaluate an - app file while *in the middle of* compiling the app poses many challenges – - in a lot of cases it is simply impossible.) - - By introducing custom components, the Glimmer compiler would need access to - the component definitions (specifically, the capabilities mask) in order to - emit the right compilation for the component. Therefore, if the only way to - access the component definition is to evaluate the component JS file, that - would pretty much make build-time compilation impossible. - - More importantly, the second issue with this approach is that because we are - planning to move the component's tag into the template (as opposed to having - *tagName*, *attributeBindings* and friends on the component class – see the - previous angle bracket components RFCs), we expect template-only components - (i.e. components without a JS file) to be relatively common. Therefore, in a - lot of cases, there won't even be a JS class to stash the definition on to, - making this solution a non-starter. - - Naturally, the solution seems to be that we should attach this information - to the templates rather than the JS files. There are two proposals for this - so far. The first proposal is to put this information in the filenames. For - example, `foo-bar.glimmer.hbs` or `foo-bar.lite.hbs`. The second proposal is - to put this infomration inside the body of the template as a "pragma", such - as `{{!use glimmer}}`, `{{!use lite}}` (insert syntax bikeshed here). In - both cases, addons can register a factory for the component definitions - based on a string key ("glimmer" and "lite" in the examples). - -2. Interactions with component helpers - - It is unclear how custom components should interact with the component - helper feature. - - While there are indeed a lot of open questions on how this feature should - work with angle bracket invocations, since this RFC does not introduce angle - bracket invocation syntax, those questions can be deferred until a future - RFC comes along to introduce that capability. - - However, while the `{{component}}` helper is implemented natively in Glimmer - and should have no problem handling custom components (since all components - are "custom" from Glimmer's perspective), it is unclear how it should work - with the "closure" `(component)` helper. - - Currently, the "closure component" feature is not implemented natively in - Glimmer. Instead, Glimmer currently exposes some low-level hooks to Ember's - classic components manager, which allows it to implement its "closure" - semantics. These hooks are unstable and might go away in future refactors. - - In the long run, we would like to implement this feature natively in Glimmer - so that it would "just work" with custom components. However, the current - "closure" semantics (specifically the ways it handles arguments currying) in - Ember is quite specific to the classic components API and are somewhat - counter-intuitive in some cases (the jury is still out, these issues might - simply be mistakes in the current implementation). - - It seems unlikely that these issues would be resolved in the timeframe of - this RFC (at minimum, it seems unwise to couple the two). Therefore, there - are a few options here – 1) we can disallow using component helpers with - custom components altogether for the MVP and relax that restriction in the - future; 2) we can allow only `{{component}}` helper but not `(component)`; - 3) we can allow both `{{component}}` and `(component)`, *however*, we will - disallow passing extra arguments to the `(component)` helper. - -# Follow-up RFCs +# Appendix + +## Follow-up RFCs We expect a few follow-up RFCs to introduce additional capabilities that are not included in this minimal proposal. These RFCs can run either in parallel @@ -1071,11 +983,154 @@ tested in the wild. 2. Expose a way to get access to the [reference][1]-based APIs. This could include the ability to customize the component's "tag" ([validator][2]). - [1]: https://github.com/tildeio/glimmer/blob/master/guides/04-references.md - [2]: https://github.com/tildeio/glimmer/blob/master/guides/05-validators.md + [1]: https://github.com/glimmerjs/glimmer-vm/blob/master/guides/04-references.md + [2]: https://github.com/glimmerjs/glimmer-vm/blob/master/guides/05-validators.md 3. Expose additional features that are used to implement classic components, `{{outlet}}` and other built-in components, such as layout customizations, and dynamic scope access. -4. Allow angle bracket invocation. +4. Angle bracket invocation. + +## Using ES6 Classes + +Although this RFC uses `Ember.Object` in the examples, it is not a "hard" +dependency. + +### Using ES6 Classes For Components + +The main interaction between the Ember object model and the component class +is through the DI system. Specifically, the factory function returned by +`factoryFor` (`factoryFor('component:foo-bar').create(...)`), which is passed +to the `createComponent` method on the component manager, assumes a static +`create` method on the class that takes the "property bag" and returns the +created instance. + +Therefore, as long as your ES6 super-class provides such a function, it will +work with the rest of the system: + +```js +// ember-basic-component/addon/index.js + +import { setComponentManager } from '@ember/component'; + +class BasicComponent { + static create(props) { + return new this(props); + } + + constructor(props) { + // Do things with props, such as: + Object.assign(this, props); + } + + // ... +} + +export default setComponentManager(BasicComponent, 'basic'); +``` + +```js +// ember-basic-component/app/component-managers/basic.js + +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; + +export default EmberObject.extend({ + capabilities: capabilities('3.2'), + + createComponent(factory, args) { + // This Just Works™ since we have a static create method on the class + return factory.create(args.named); + }, + + // ... +}); +``` + +```js +// my-app/app/components/foo-bar.js + +import BasicCompoment from 'ember-basic-component'; + +export default class extends BasicCompoment { + // ... +}; +``` + +Alternatively, if you prefer not to add a static create method to your +super-class, you can also instantiate them in the component manager without +going through the DI system: + +```js +// ember-basic-component/app/component-managers/basic.js + +import { capabilities } from '@ember/component'; +import EmberObject from '@ember/object'; + +export default EmberObject.extend({ + capabilities: capabilities('3.2'), + + createComponent(factory, args) { + // This does not use the factory function, thus no longer require a static + // create method on the class + return new factory.class(args.named); + }, + + // ... +}); +``` + +However, doing do will prevent your components from receiving injections (as +well as setting the appropiate owner, etc). Therefore, when possible, it is +better to go through the DI system's factory function. + +### Using ES6 Classes For Component Managers + +It is also possible to use ES6 classes for the component managers themselves. +The main interaction here is that they are automatically instantiated by the DI +system on-demand, which again assumes a static `create` method: + +```js +// ember-basic-component/app/component-managers/basic.js + +import { capabilities } from '@ember/component'; + +export default class BasicComponentManager { + static create(props) { + return new this(props); + } + + constructor(props) { + // Do things with props, such as: + Object.assign(this, props); + } + + capabilities = capabilities('3.2'); + + // ... +}; +``` + +Alternatively, as shown above, you can also register the component manager +with `{ instantiate: false }`: + +```js +// ember-basic-component/app/initializers/register-basic-component-manager.js + +import BasicComponentManager from 'ember-basic-component'; + +export function initialize(application) { + application.register('component-manager:basic', new BasicComponentManager(), { instantiate: false }); +} + +export default { + name: 'register-basic-component-manager', + initialize +}; +``` + +Note that this behaves a bit differently as the component manager instance is +shared across all application instances and is never destroyed, which might +affect stateful component managers such as the one shown in the "Recycling +Components" example above. From 483654e8cc8038bd25ede57e5eab53b24549c3a7 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 5 Mar 2018 10:33:44 -0800 Subject: [PATCH 10/13] remove inspector support section --- text/0000-custom-components.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 8a851d402a..45a7c69703 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -669,29 +669,6 @@ the exact timing of the calls are also undefined. For example, the calls from several render loops might be batched together and deferred into a browser idle callback. -## Inspector Support - -When the `inspectorSupport` capability is set to `true`, the component manager -is expected to implement an additional method: `inspectComponent`. - -`destroyComponent` will be called when the component is no longer needed. This -is intended for performing object-model level cleanup. - -Because this RFC does not provide ways to access or observe the component's DOM -tree, the timing relative to DOM teardown is undefined (i.e. whether this is -called before or after the component's DOM tree is removed from the document). - -Therefore, this hook is not suitable for invoking user callbacks intended for -performing DOM cleanup, such as `willDestroyElement` in the classic components -API. We expect a subsequent RFC addressing DOM-related functionalities to -clearify this issues or provide another specialized method for that purpose. - -Similar to the other async lifecycle callbacks, this API provides no guarentee -about ordering with respect to siblings or parent-child relationships. Further, -the exact timing of the calls are also undefined. For example, the calls from -several render loops might be batched together and deferred into a browser idle -callback. - # Examples ## Basic Component Manager From 68b62a8485eba11118893bba78fbf21f3fef89c6 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Thu, 8 Mar 2018 17:50:33 -0800 Subject: [PATCH 11/13] updates to address feedback --- text/0000-custom-components.md | 203 +++++++++++++++++++++------------ 1 file changed, 128 insertions(+), 75 deletions(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 45a7c69703..6d25c70f4d 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -1,6 +1,6 @@ - Start Date: 2017-03-13 -- RFC PR: (leave this empty) -- Ember Issue: (leave this empty) +- RFC PR: https://github.com/emberjs/rfcs/pull/213 +- Ember Issue: https://github.com/emberjs/ember.js/issues/16301 # Summary @@ -8,15 +8,15 @@ This RFC aims to expose a _low-level primitive_ for defining _custom components_. This API will allow addon authors to provide special-purpose component base -classes that their users can subclass from in the apps. These components are +classes that their users can subclass from in apps. These components are invokable in templates just like any other Ember components (decendents of `Ember.Component`) today. # Motivation -The ability to author reusable, composable components is a core features of +The ability to author reusable, composable components is a core feature of Ember.js. Despite being a [last-minute addition](http://emberjs.com/blog/2013/06/23/ember-1-0-rc6.html) -to Ember 1.0, the `Ember.Component` API and programming mode has proven itself +to Ember 1.0, the `Ember.Component` API and programming model has proven itself to be an extremely versatile tool and has aged well over time into the primary unit of composition in Ember's view layer. @@ -87,8 +87,7 @@ objects (i.e. `{ singleton: true, instantiate: true }`), meaning that Ember will create and maintain (at most) one instance of each unique component manager for every application instance. -To register a component manager, an addon will typically put it inside its -`app` tree: +To register a component manager, an addon will put it inside its `app` tree: ```js // ember-basic-component/app/component-managers/basic.js @@ -100,6 +99,10 @@ export default EmberObject.extend({ }); ``` +(Typically, the convention is for addons to define classes like this in its +`addon` tree and then re-export them from the `app` tree. For brevity, we will +just inline them in the `app` tree directly for the examples in this RFC.) + This allows the component manager to participate in the DI system – receiving injections, using services, etc. Alternatively, component managers can also be registered with imperative API. This could be useful for testing or opt-ing @@ -146,29 +149,13 @@ class: import EmberObject from '@ember/object'; import { setComponentManager } from '@ember/component'; -const MyComponent = EmberObject.extend({ +export default setComponentManager('awesome', EmberObject.extend({ // ... -}); - -setComponentManager(MyComponent, 'awesome'); - -export default MyComponent; +})); ``` This tells Ember to use the `awesome` manager (`component-manager:awesome`) for -the `foo-bar` component. Since the `setComponentManager` function also returns -the class, this can also be simplified as: - -```js -// my-app/app/components/foo-bar.js - -import EmberObject from '@ember/object'; -import { setComponentManager } from '@ember/component'; - -export default setComponentManager(EmberObject.extend({ - // ... -}), 'awesome'); -``` +the `foo-bar` component. `setComponentManager` function returns the class. In the future, this function can also be invoked as a decorator: @@ -178,16 +165,15 @@ In the future, this function can also be invoked as a decorator: import EmberObject from '@ember/object'; import { componentManager } from '@ember/component'; -@componentManager('awesome') -export default EmberObject.extend({ +export default @componentManager('awesome') EmberObject.extend({ // ... }); ``` -In reality, app developer would never have to write this in their apps, since -the component manager would already be assigned on a super-class provided by -the framework or an addon. The `setComponentManager` function is essentially a -low-level API designed for addon authors and not intended to be used by app +In reality, an app developer would never have to write this in their apps, +since the component manager would already be assigned on a super-class provided +by the framework or an addon. The `setComponentManager` function is essentially +a low-level API designed for addon authors and not intended to be used by app developers. For example, the `Ember.Component` class would have the `classic` component @@ -212,10 +198,9 @@ Similarly, an addon can provided the following super-class: import EmberObject from '@ember/object'; import { componentManager } from '@ember/component'; -@componentManager('basic') -export default EmberObject.extend({ +export default setComponentManager('basic', EmberObject.extend({ // ... -}); +})); ``` With this, app developers can simply inherit from this in their app: @@ -223,9 +208,9 @@ With this, app developers can simply inherit from this in their app: ```js // my-app/app/components/foo-bar.js -import TurboComponent from 'ember-basic-component'; +import BasicComponent from 'ember-basic-component'; -export default TurboComponent.extend({ +export default BasicComponent.extend({ // ... }); ``` @@ -457,7 +442,7 @@ Imagine if `post.title` changed from `fOo BaR` to `FoO bAr`. Since the value is passed through the `uppercase` helper, the component will see `FOO BAR` in both cases. -Generally speaking, Ember does not provide any guarentee on how it determine +Generally speaking, Ember does not provide any guarentee on how it determines whether components need to be re-rendered, and the semantics may vary between releases – i.e. this method may be called more or less often as the internals changes. The _only_ guarentee is that if something _has_ changed, this method @@ -469,6 +454,27 @@ the extra book-keeping. For example: +```js +// ember-basic-component/index.js + +import EmberObject from '@ember/object'; +import { setComponentManager } from '@ember/component'; + +function NOOP() {} + +export default setComponentManager('basic', EmberObject.extend({ + // Users of BasicComponent can override this hook to be notified when an + // argument will change + argumentWillChange: NOOP, + + // Users of BasicComponent can override this hook to be notified when an + // argument will change + argumentDidChange: NOOP, + + // ... +})); +``` + ```js // ember-basic-component/app/component-managers/basic.js @@ -496,7 +502,7 @@ export default EmberObject.extend({ // generally assume that they have exactly the same keys. However, future // additions such as "splat arguments" in the template layer might change // that assumption. - for (let key of Object.keys(oldArgs)) { + for (let key in oldArgs) { let oldValue = oldArgs[key]; let newValue = newArgs[key]; @@ -524,7 +530,14 @@ template to reflect any changes. ## Capabilities In addition to the methods specified above, component managers are required to -have a `capabilities` property: +have a `capabilities` property. This property must be set to the result of +calling the `capabilities` function provided by Ember. + +### Versioning + +The first, mandatory, argument to the `capabilities` function is the component +manager API, which is denoted in the `${major}.${minor}` format, matching the +minimum Ember version this manager is targeting. For example: ```js // ember-basic-component/app/component-managers/basic.js @@ -549,23 +562,16 @@ export default EmberObject.extend({ }); ``` -This property must be set to the result of calling the `capabilities` function -provided by Ember. - -The first, mandatory, argument to the `capabilities` function is the component -manager API, which is denoted in the `${major}.${minor}` format, matching the -minimum Ember version this manager is targeting. - This allows Ember to introduce new capabilities and make improvements to this API without breaking existing code. Here is a hypothical scenario for such a change: 1. Ember 3.2 implemented and shipped the component manager API as described in - this API. + this RFC. -2. The `ember-basic-component` addon releases its first version with the - component manager shown above (notably, it declared `capabilities('3.2')`). +2. The `ember-basic-component` addon released version 1.0 with the component + manager shown above (notably, it declared `capabilities('3.2')`). 3. In Ember 3.5, we determined that constructing the arguments object passed to the hooks is a major performance bottleneck, and changes the API to pass a @@ -579,13 +585,60 @@ Here is a hypothical scenario for such a change: 4. The `ember-basic-component` addon author would like to take advantage of this performance optimization, so it updates its component manager code to work with the arguments proxy and changes its capabilities declaration to - `capabilities('3.5')` in a major release. + `capabilities('3.5')` in version 2.0. + +This system allows us to rapidly improve the API and take advantage of +underlying rendering engine features as soon as they become available. + +Note that addon authors are not _required_ to update to the newer API. +Concretely, component manager APIs have the following support policy: + +* API versions will continue to be supported in the same major release of + Ember. As shown in the example above, `ember-basic-component` 1.0 (which + targets component manager API version 3.2), will continue to work on + Ember 3.5. However, the reverse is not true – component manager API version + 3.5 will (somewhat obviously) not work in Ember 3.2. + +* In addition, to ensure a smooth transition path for addon authors and app + developers across major releases, each Ember version will support (at least) + the previous LTS version as of the release was made. For example, if 3.16 is + the last LTS release of the 3.x series, the component manager API version + 3.16 will be supported by Ember 4.0 through 4.4, at minimum. + +Addon authors can also choose to target multiple versions of the component +manager API using [ember-compatibility-helpers](https://github.com/pzuraq/ember-compatibility-helpers/): + +```js +// ember-basic-component/app/component-managers/basic.js + +import { gte } from 'ember-compatibility-helpers'; + +let ComponentManager; + +if (gte('3.5')) { + ComponentManager = EmberObject.extend({ + capabilities: capabilities('3.5'), + + // ... + }); +} else { + ComponentManager = EmberObject.extend({ + capabilities: capabilities('3.2'), + + // ... + }); +} + +export default ComponentManager; +``` + +Since the conditionals are resolved at build time, the irrevelant code will be +stripped from production builds, avoiding any deprecation warnings. -This system allows us to rapidly improve the API and take advantage of the -underlying rendering engine featuers as soon as they become available. +### Optional Features -The second, optional, argument to the `capabilities` is an object enumerating -the optional features requested by the component manager. +The second, optional, argument to the `capabilities` function is an object +enumerating the optional features requested by the component manager. In the hypothical example above, while the "reified" arguments objects may be a little slower, they are certainly easier to work with, and the performance @@ -661,7 +714,7 @@ called before or after the component's DOM tree is removed from the document). Therefore, this hook is not suitable for invoking user callbacks intended for performing DOM cleanup, such as `willDestroyElement` in the classic components API. We expect a subsequent RFC addressing DOM-related functionalities to -clearify this issues or provide another specialized method for that purpose. +clarify this issues or provide another specialized method for that purpose. Similar to the other async lifecycle callbacks, this API provides no guarentee about ordering with respect to siblings or parent-child relationships. Further, @@ -712,7 +765,7 @@ export default EmberObject.extend({ import EmberObject from '@ember/object'; import { setComponentManager } from '@ember/component'; -export default setComponentManager(EmberObject.extend(), 'basic'); +export default setComponentManager('basic', EmberObject.extend()); ``` ### Usage @@ -793,7 +846,7 @@ import { setComponentManager } from '@ember/component'; // Our `createComponent` method does not actually do anything with the factory, // so we don't even need to export a class here, `{}` would work just fine. -export default setComponentManager({}, 'template-only'); +export default setComponentManager('template-only', {}); ``` ### Usage @@ -813,7 +866,7 @@ Hello world! I have no backing class! {{this}} would be null. ## Recycling Components This example implements an API which maintain a pool of recycled component -instances to avoid allocation costs, similar to Flex's "sustain" feature. +instances to avoid allocation costs, similar to [flexi-sustain](https://github.com/html-next/flexi-sustain). This example also make use of the "state bucket" pattern. @@ -898,12 +951,12 @@ import { setComponentManager } from '@ember/component'; function NOOP() {} -export default setComponentManager(EmberObject.extend({ +export default setComponentManager('pooled', EmberObject.extend({ // Override this to implement reset any state on the instance willRecycle(): NOOP, // ... -}), 'pooled'); +})); ``` # How We Teach This @@ -919,10 +972,10 @@ do not expect the guides need to be updated for this feature (at least not the components section). For documentation purposes, each Ember.js release will only document the latest -component manager API. The documentation will also include the steps needed to -upgrade, as well as a list of new capabilities added since the last version. -Documentation for a specific version of the component manager API can be viewed -from the versioned documentation site. +component manager API, along with the available optional capabilities for that +realease. The documentation will also include the steps needed to upgrade from +the previous version. Documentation for a specific version of the component +manager API can be viewed from the versioned documentation site. # Drawbacks @@ -948,17 +1001,17 @@ without rationalizing or exposing the underlying primitives. ## Follow-up RFCs -We expect a few follow-up RFCs to introduce additional capabilities that are -not included in this minimal proposal. These RFCs can run either in parallel -to this RFC or be submitted after this initial RFC has been implemented and -tested in the wild. +We expect to rapidly iterate and improve the component manager API through the +RFC process and in-the-field usage/implementation experience. Here are a few +examples of additional capabilities that we hope to see proposed after this +initial (and intentionally minimal) proposal is finalized: -1. Expose a way to get access to the component's DOM structure, such as its - element and bounds. This RFC would also need to introduce a hook for DOM - teardown and address how event handling/delegation would work. +1. Expose a way to access to the component's DOM structure, such as its bounds. + This RFC would also need to introduce a hook for DOM teardown and address + how event handling/delegation would work. -2. Expose a way to get access to the [reference][1]-based APIs. This could - include the ability to customize the component's "tag" ([validator][2]). +2. Expose a way to access to the [reference][1]-based APIs. This could include + the ability to customize the component's "tag" ([validator][2]). [1]: https://github.com/glimmerjs/glimmer-vm/blob/master/guides/04-references.md [2]: https://github.com/glimmerjs/glimmer-vm/blob/master/guides/05-validators.md @@ -1004,7 +1057,7 @@ class BasicComponent { // ... } -export default setComponentManager(BasicComponent, 'basic'); +export default setComponentManager('basic', BasicComponent); ``` ```js From 71030ea423015db320f1ed269e5ed8fcb362ea33 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Fri, 9 Mar 2018 14:28:15 -0800 Subject: [PATCH 12/13] don't mention flexi-sustain --- text/0000-custom-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 6d25c70f4d..47195db3f6 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -866,7 +866,7 @@ Hello world! I have no backing class! {{this}} would be null. ## Recycling Components This example implements an API which maintain a pool of recycled component -instances to avoid allocation costs, similar to [flexi-sustain](https://github.com/html-next/flexi-sustain). +instances to avoid allocation costs. This example also make use of the "state bucket" pattern. From 154f5d8440a2aa4477b589368987a6476e8897b5 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Wed, 21 Mar 2018 15:16:54 +0000 Subject: [PATCH 13/13] typo --- text/0000-custom-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0000-custom-components.md b/text/0000-custom-components.md index 47195db3f6..d796e2c900 100644 --- a/text/0000-custom-components.md +++ b/text/0000-custom-components.md @@ -9,7 +9,7 @@ components_. This API will allow addon authors to provide special-purpose component base classes that their users can subclass from in apps. These components are -invokable in templates just like any other Ember components (decendents of +invokable in templates just like any other Ember components (descendants of `Ember.Component`) today. # Motivation