From bac131493fb06ef080f11f1ce1617172fe7fc9b8 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 30 Apr 2020 14:24:48 -0700 Subject: [PATCH 1/9] Adds Helper Manager RFC --- text/0000-helper-managers.md | 504 +++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 text/0000-helper-managers.md diff --git a/text/0000-helper-managers.md b/text/0000-helper-managers.md new file mode 100644 index 0000000000..474ee61b1f --- /dev/null +++ b/text/0000-helper-managers.md @@ -0,0 +1,504 @@ +- Start Date: 2020-04-28 +- Relevant Team(s): Ember.js +- RFC PR: (after opening the RFC PR, update this with a link to it and update the file name) +- Tracking: (leave this empty) + +# Helper Managers + +## Summary + +Provides a low-level primitive for defining helpers. + +## Motivation + +Helpers are currently the only template construct in Ember that do not have a +low-level primitive that is public. With components and modifiers, users can define +component managers and modifier managers respectively to create their own high +level APIs, but for helpers the only option currently is to use the high level +`helper()` wrapper function, or the `Helper` base class. + +These APIs are beginning to show their age in Ember Octane, and unlocking +experimentation via a helper manager would allow us to begin designing a new +generation of helpers. Some possible areas to explore here would include: + +* Using a native base class for helpers, instead of `EmberObject` +* Adding lifecycle hooks, similar to modifiers, to class-based helpers +* Adding the ability to inject services to functional helpers +* Allowing normal functions to operate as helpers + +In addition, it would allow us to begin adding new functionality to helpers via +manager capabilities. This RFC proposes one such capability, `isScheduledEffect`. + +### Effect Helpers + +Usually, template helpers are supposed to return a value. However, if a helper +returns `undefined` and rendered in a template, it will produce no output. This +can be used to accomplish a _side-effect_: + +```js +// app/helpers/title.js +export default helper(([title]) => { + window.document.title = title; +}); +``` + +```hbs +{{title "My Document Title"}} +``` + +Addons such as [ember-page-title](https://github.com/adopted-ember-addons/ember-page-title) +use this to make helpers that can be added to the template to specify +_app behavior_ declaratively. This is a much better way to approach certain +types of behavior and APIs, compared to the alternative of using mixins and +lifecycle hooks to manage them. Its benefits include: + +1. Behavior can be _self-contained_. Some APIs need to run at multiple points in + based on a component's lifecycle, such as for a plugin that needs to be setup + on initialization and torn down upon destruction. Using lifecycle hooks for + this forces users to split their API across multiple touch points in a + component, which requires a lot of boilerplate and can make it difficult to + understand how the whole system works together. + +2. It doesn't require _multiple inheritance_. Mixins and strategies like them + create complicated inheritance hierarchies that can be difficult to debug. + A side-effecting helper does not insert itself into the inheritance + hierarchy, it is a child of the template instead, which is much easier to + reason about in practice. + +3. They are highly _composable_. Helpers, like components, can be used multiple + times in a template and can be used within `{{if}}` and `{{each}}` blocks. + Combined with their ability to be hold a self contained lifecycle, this makes + them a powerful tool for composing declarative behavior. + +4. They can be _destroyed_. They tie in naturally to the destruction APIs that + we have recently added to Ember, and that allows their lifecycle to be + managed in a self contained way. + +However, this pattern has some issues today, mostly stemming from the fact that +they execute _during_ render, which is not an ideal time for side-effecting. +They can also be abused to modify app state, which can lead to difficult to +follow code paths reminiscent of observers. These issues stem from a mismatch +between two different goals, the goal of calculating a result or value, and the +goal of triggering side-effects. + +The `isScheduledEffect` capability would schedule side-effecting helpers to execute +_after_ render, and would _disable_ Ember's state mutations while they were +running. This would ensure that side-effecting helpers run at the optimal time, +and do not enable antipatterns and complicated codepaths. + +## Detailed design + +This RFC proposes adding the `setHelperManager` and `capabilities` APIs, +imported from `@ember/helper`. Like `setComponentManager` and +`setModifierManager`, `setHelperManager` receives a callback that is passed +the owner, and returns an instance of the helper manager. When a helper +definition is resolved by Ember, it will look up the manager recursively on the +definition's prototype chain until it finds a helper manager. If it does not +find one, it will throw an error. + +```ts +type HelperDefinition = object; + +export declare function setHelperManager( + factory: (owner: Owner) => HelperManager, + definition: HelperDefinition +): HelperDefinition; +``` + +And like the `capabilities` functions for component and modifier managers, the +`capabilities` function for helper managers receives a version string as the +first parameter and a options object as the second with optional flags as +booleans. It produces an opaque `HelperCapabilities` object, which is assigned +to the helper manager. + +```ts +interface HelperCapabilitiesOptions { + hasValue?: boolean; + hasDestroyable?: boolean; + isScheduledEffect?: boolean; +} + +type HelperCapabilities = Opaque; + +export declare function capabilities( + version: string, + options?: HelperCapabilitiesOptions +): HelperCapabilities; +``` + +Helper managers themselves have the following interface: + +```ts +interface HelperManager { + capabilities: HelperCapabilities; + + createHelper(definition: HelperDefinition, args: TemplateArgs): HelperStateBucket; + + getValue?(bucket: HelperStateBucket, args: TemplateArgs): unknown; + + runEffect?(bucket: HelperStateBucket, args: TemplateArgs): void; + + getDestroyable?(bucket: HelperStateBucket): object; +} +``` + +Let's dig into these hooks one by one: + +### Hooks + +#### `createHelper` + +`createHelper` is a required hook on the HelperManager interface. The hook is +passed the definition of the helper that is currently being created, and is +expected to return a _state bucket_. This state bucket is what represents the +current state of the helper, and will be passed to the other lifecycle hooks at +appropriate times. It is not necessarily related to the definition of the +helper itself - for instance, you could return an object _containing_ an +instance of the helper: + +```js +class MyManager { + createHelper(Definition, args) { + return { + instance: new Definition(args); + }; + } +} +``` + +This allows the manager to store metadata that it doesn't want to expose to the +user. + +This hook has the following timing semantics: + +**Always** +- called as discovered during DOM construction +- called in definition order in the template + +#### `getValue` + +`getValue` is an optional hook that should return the value of the helper. This +is the value that is returned from the helper and passed into the template. + +This hook is called when the value is requested from the helper (e.g. when the +template is rendering and the helper value is needed). The hook is autotracked, +and will rerun whenever any tracked values used inside of it are updated. +Otherwise it does not rerun. + +> Note: This means that arguments which are not _consumed_ within the hook will +> not trigger updates. + +This hook is only called for helpers with the `hasValue` capability enabled. +This hook has the following timing semantics: + +**Always** +- called the first time the helper value is requested +- called after autotracked state has changed + +**Never** +- called if the `hasValue` capability is disabled + +#### `runEffect` + +`runEffect` is an optional hook that should run the effect that the helper is +applying, setting it up or updating it. + +This hook is scheduled to be called after render. The hook is autotracked, and +will rerun whenever any tracked values used inside of it are updated. Otherwise +it does not rerun. + +The hook is also run during a time period where state mutations are _disabled_ +in Ember. Any change to any tracked property or tag via `Ember.set` will throw +an error during this time. This is meant to prevent infinite rerenders and other +antipatterns. + +This hook is only called for helpers with the `isScheduledEffect` capability +enabled. It has the following timing semantics: + +**Always** +- called after the helper was first created +- called after autotracked state has changed + +**Never** +- called if the `isScheduledEffect` capability is disabled + +#### `getDestroyable` + +`getDestroyable` is an optional hook that users can use to register a +destroyable object for the helper. This destroyable will be registered to the +containing block or template parent, and will be destroyed when it is destroyed. +See the [Destroyables RFC](https://github.com/emberjs/rfcs/blob/master/text/0580-destroyables.md) +for more details. + +`getDestroyable` is only called if the `hasDestroyable` capability is enabled. + +This hook has the following timing semantics: + +**Always** +- called immediately after the `createHelper` hook is called + +**Never** +- called if the `hasDestroyable` capability is disabled + +### Capabilities + +There are three proposed capabilities for helper managers: + +* `hasDestroyable` +* `isScheduledEffect` +* `hasValue` + +Out of these capabilities, one of `isScheduledEffect` or `hasValue` _must_ be +enabled. The other must _not_ be enabled, meaning they are mutually exclusive. + +#### `hasDestroyable` + +Determines if the helper has a destroyable to include in the destructor +hierarchy. If enabled, the `getDestroyable` hook will be called, and its result +will be associated with the destroyable parent block. + +#### `hasValue` + +Determines if the helper has a value which can be used externally. The helper's +`getValue` hook will be run whenever the value of the helper is accessed if this +capability is enabled. + +#### `isScheduledEffect` + +Determines if the helper is scheduled. If enabled, the helper's `runEffect` hook +will run after render, and will not allow any type of state mutation when +running. + +### Scheduled Helpers Timing + +Scheduled helpers run their effects after render, and after modifiers have been +applied for a given render, but before paint. The exact timing may shift around, +and may or may not correspond to a single rendering pass in cases where there +are multiple rendering passes in a single paint. + +In the future different timings may be added as options for scheduling. For +instance, a timing to call the effect using `requestIdleCallback`, when the +browser has finished rendering and handling higher priority work, could be +added. However, this is out of scope for this RFC. + +## How we teach this + +Helper managers are a low-level construct that is generally only meant to be +used by experts and addon authors. As such, it will only be taught through API +documentation. In addition, for precision and clarity, the API docs will include +snippets of TypeScript interfaces where appropriate. + +### API Docs + +#### `setHelperManager` + +Sets the helper manager for an object or function. + +```js +setHelperManager((owner) => new ClassHelperManager(owner), Helper.prototype) +``` + +When a value is used as a helper in a template, the helper manager is looked up +by on the object by walking up its prototype chain and finding the first helper +manager. This manager then receives the value and can create and manage an +instance of a helper from it. This provides a layer of indirection that allows +users to design high-level helper APIs, without Ember needing to worry about the +details. High-level APIs can be experimented with and iterated on while the +core of Ember helpers remains stable, and new APIs can be introduced gradually +over time to existing code bases. + +`setHelperManager` receives two arguments: + +1. A factory function, which receives the `owner` and returns an instance of a + helper manager. +2. The object to associate the factory function with. + +The first time the object is looked up, the factory function will be called to +create the helper manager. It will be cached, and in subsequent lookups it the +cached helper manager will be used instead. + +Only one helper manager exists per helper factory, so many helpers will end up +using the same instance of the helper manager. As such, you should not store any +state on the helper manager that is related to a single helper instance. + +Helper managers must fulfill the following interface (This example uses +[TypeScript interfaces](https://www.typescriptlang.org/docs/handbook/interfaces.html) +for precision, you do not need to write helper managers using TypeScript): + +```ts +interface HelperManager { + capabilities: HelperCapabilities; + + createHelper(definition: HelperDefinition, args: TemplateArgs): HelperStateBucket; + + getValue?(bucket: HelperStateBucket, args: TemplateArgs): unknown; + + runEffect?(bucket: HelperStateBucket, args: TemplateArgs): void; + + getDestroyable?(bucket: HelperStateBucket): object; +} +``` + +The capabilities property should be provided using the `capabilities()` function +imported from the same module as `setHelperManager`: + +```js +import { capabilities } from '@ember/helper'; + +class MyHelperManager { + capabilities = capabilities(); +} +``` + +Below is a description of each of the methods on the interface and their +functions. + +> The remaining API docs should be copied from the descriptions in the Detailed +> Design section of this RFC. + +#### `capabilities` + +`capabilities` returns a capabilities configuration which can be used to modify +the behavior of the manager. Manager capabilities _must_ be provided using the +`capabilities` function, as the underlying implementation can change over time. + +The first argument to capabilities is a version string, which is the version of +Ember that the capabilities were defined in. Ember can add new versions at any +time, and these may have entirely different behaviors, but it will not remove +old versions until the next major version. + +```js +capabilities('3.x'); +``` + +The second argument is an optional object of capabilities and boolean values +indicating whether they are enabled or disabled. + +```js +capabilities('3.x', { + hasValue: true, + hasDestructor: true, +}); +``` + +If no value is specified, then the default value will be used. + +##### `3.x` capabilities + +> The remaining API docs should be copied from the descriptions in the Detailed +> Design section of this RFC. 3.x above should be replaced with the version that +> helper managers are initially released in. + +## Drawbacks + +- Adds a layer of indirection to helpers, which could add to complexity and + cost in terms of performance. This isn't likely, as we haven't seen this + happen with other managers we've introduced. + +## Alternatives + +- We could continue using the current helper APIs, and try to incrementally + migrate them to only use native classes. This wouldn't match the strategy + we've taken with other template constructs, like components and modifiers, and + would result in less ability for the community to experiment and less + flexibility if we chose to change helpers again in the future. + +- The `isScheduledEffect` capability could be broken out into a separate RFC. + It is mostly separable, except for the impact it has on `getValue`. Value-less + and effect-less helpers don't really make sense, so in isolation `getValue` + would probably not be an optional hook, and the `hasValue` capability wouldn't + exist. + + Capabilities can change from version to version, so this is still not a major + issue, but it seems like it would be easier to add from the get go. + +## Appendix + +### Implementation of Current Helper APIs + +The following is an implementation of the current helper APIs using this manager +API. There are two separate managers, one for class based helpers and one for +functional helpers: + +```js +import EmberObject from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { setHelperManager, capabilities } from '@ember/helper'; + +const RECOMPUTE = symbol(); + +export class Helper extends EmberObject { + @tracked [RECOMPUTE]; + + constructor(...args) { + super(...args); + + registerDestructor(this, () => this.destroy()); + } + + recompute() { + // update the value to force a recompute + this[RECOMPUTED] = undefined; + } +} + +class ClassHelperManager { + capabilities = capabilities({ + hasValue: true, + hasDestroyable: true, + }); + + ownerInjection = {}; + + constructor(owner) { + setOwner(this.ownerInjection, owner); + } + + createHelper(Definition) { + let helper = Definition.create(this.ownerInjection); + + return { helper }; + } + + getValue({ helper }, args) { + // Consume the RECOMPUTE tag, so if anyone ever + // calls recompute() it'll force a recompute + helper[RECOMPUTE]; + + return helper.compute(args.positional, args.named); + } + + getDestroyable({ helper }) { + return helper; + } +} + +setHelperManager((owner) => new ClassHelperManager(owner), Helper.prototype); +``` + +```js +import { tracked } from '@glimmer/tracking'; +import { setHelperManager, capabilities } from '@ember/helper'; + +class FunctionalHelperManager { + capabilities = capabilities({ + hasValue: true, + }); + + createHelper(fn) { + return { fn }; + } + + getValue({ fn }, args) { + return fn(args.positional, args.named); + } +} + +const FUNCTIONAL_HELPER_MANAGER = () => new FunctionalHelperManager(); + +export function helper(fn) { + setHelperManager(FUNCTIONAL_HELPER_MANAGER, fn); + + return fn; +} +``` From 1a4221c9cb2315f801b23f75c46ee133dabc0351 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Wed, 6 May 2020 14:17:15 -0700 Subject: [PATCH 2/9] rename file --- text/{0000-helper-managers.md => 0625-helper-managers.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename text/{0000-helper-managers.md => 0625-helper-managers.md} (100%) diff --git a/text/0000-helper-managers.md b/text/0625-helper-managers.md similarity index 100% rename from text/0000-helper-managers.md rename to text/0625-helper-managers.md From 1d1f85428224b83a95eb28015f57a829cee28b3e Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Sat, 9 May 2020 10:50:04 -0700 Subject: [PATCH 3/9] Apply suggestions from code review Co-authored-by: Robert Jackson --- text/0625-helper-managers.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 474ee61b1f..1f32f9c92d 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -52,7 +52,7 @@ _app behavior_ declaratively. This is a much better way to approach certain types of behavior and APIs, compared to the alternative of using mixins and lifecycle hooks to manage them. Its benefits include: -1. Behavior can be _self-contained_. Some APIs need to run at multiple points in +1. Behavior can be _self-contained_. Some APIs need to run at multiple points in time based on a component's lifecycle, such as for a plugin that needs to be setup on initialization and torn down upon destruction. Using lifecycle hooks for this forces users to split their API across multiple touch points in a @@ -115,13 +115,14 @@ to the helper manager. interface HelperCapabilitiesOptions { hasValue?: boolean; hasDestroyable?: boolean; - isScheduledEffect?: boolean; + hasScheduledEffect?: boolean; } type HelperCapabilities = Opaque; export declare function capabilities( - version: string, + // to be replaced with the version of Ember this lands in + version: '3.21.0', options?: HelperCapabilitiesOptions ): HelperCapabilities; ``` @@ -299,7 +300,7 @@ setHelperManager((owner) => new ClassHelperManager(owner), Helper.prototype) ``` When a value is used as a helper in a template, the helper manager is looked up -by on the object by walking up its prototype chain and finding the first helper +on the object by walking up its prototype chain and finding the first helper manager. This manager then receives the value and can create and manage an instance of a helper from it. This provides a layer of indirection that allows users to design high-level helper APIs, without Ember needing to worry about the @@ -311,10 +312,10 @@ over time to existing code bases. 1. A factory function, which receives the `owner` and returns an instance of a helper manager. -2. The object to associate the factory function with. +2. A helper definition, which is the object or function to associate the factory function with. The first time the object is looked up, the factory function will be called to -create the helper manager. It will be cached, and in subsequent lookups it the +create the helper manager. It will be cached, and in subsequent lookups the cached helper manager will be used instead. Only one helper manager exists per helper factory, so many helpers will end up @@ -346,7 +347,9 @@ imported from the same module as `setHelperManager`: import { capabilities } from '@ember/helper'; class MyHelperManager { - capabilities = capabilities(); + capabilities = capabilities('3.21.0', { hasValue: true }); + + // ...snip... } ``` From 939bee0489e3aaebe441c5664886eea01a77f2b7 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Sat, 9 May 2020 11:12:30 -0700 Subject: [PATCH 4/9] add example and respond to feedback --- text/0625-helper-managers.md | 380 +++++++++++++++++++++++++++++++++-- 1 file changed, 359 insertions(+), 21 deletions(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 1f32f9c92d..0919c20329 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -27,7 +27,7 @@ generation of helpers. Some possible areas to explore here would include: * Allowing normal functions to operate as helpers In addition, it would allow us to begin adding new functionality to helpers via -manager capabilities. This RFC proposes one such capability, `isScheduledEffect`. +manager capabilities. This RFC proposes one such capability, `hasScheduledEffect`. ### Effect Helpers @@ -81,7 +81,7 @@ follow code paths reminiscent of observers. These issues stem from a mismatch between two different goals, the goal of calculating a result or value, and the goal of triggering side-effects. -The `isScheduledEffect` capability would schedule side-effecting helpers to execute +The `hasScheduledEffect` capability would schedule side-effecting helpers to execute _after_ render, and would _disable_ Ember's state mutations while they were running. This would ensure that side-effecting helpers run at the optimal time, and do not enable antipatterns and complicated codepaths. @@ -97,6 +97,7 @@ definition's prototype chain until it finds a helper manager. If it does not find one, it will throw an error. ```ts +// object is used here to mean any valid WeakMap key type HelperDefinition = object; export declare function setHelperManager( @@ -123,7 +124,7 @@ type HelperCapabilities = Opaque; export declare function capabilities( // to be replaced with the version of Ember this lands in version: '3.21.0', - options?: HelperCapabilitiesOptions + options: HelperCapabilitiesOptions ): HelperCapabilities; ``` @@ -170,6 +171,13 @@ class MyManager { This allows the manager to store metadata that it doesn't want to expose to the user. +This hook is _not_ autotracked - changes to tracked values used within this hook +will _not_ result in a call to any of the other lifecycle hooks. If users do +want to autotrack some values used during construction, they can either create +the instance of the helper in `runEffect` or `getValue`, or they can use the +`cache` API to autotrack the `createHelper` hook themselves. This provides +maximum flexibility and expressiveness to manager authors. + This hook has the following timing semantics: **Always** @@ -209,11 +217,12 @@ will rerun whenever any tracked values used inside of it are updated. Otherwise it does not rerun. The hook is also run during a time period where state mutations are _disabled_ -in Ember. Any change to any tracked property or tag via `Ember.set` will throw -an error during this time. This is meant to prevent infinite rerenders and other -antipatterns. +in Ember. Any tracked state mutation will throw an error during this time, +including changes to tracked properties, changes made using `Ember.set`, updates +to computed properties, etc. This is meant to prevent infinite rerenders and +other antipatterns. -This hook is only called for helpers with the `isScheduledEffect` capability +This hook is only called for helpers with the `hasScheduledEffect` capability enabled. It has the following timing semantics: **Always** @@ -221,7 +230,7 @@ enabled. It has the following timing semantics: - called after autotracked state has changed **Never** -- called if the `isScheduledEffect` capability is disabled +- called if the `hasScheduledEffect` capability is disabled #### `getDestroyable` @@ -246,29 +255,35 @@ This hook has the following timing semantics: There are three proposed capabilities for helper managers: * `hasDestroyable` -* `isScheduledEffect` * `hasValue` +* `hasScheduledEffect` -Out of these capabilities, one of `isScheduledEffect` or `hasValue` _must_ be +Out of these capabilities, one of `hasScheduledEffect` or `hasValue` _must_ be enabled. The other must _not_ be enabled, meaning they are mutually exclusive. #### `hasDestroyable` +- Default value: false + Determines if the helper has a destroyable to include in the destructor hierarchy. If enabled, the `getDestroyable` hook will be called, and its result will be associated with the destroyable parent block. #### `hasValue` +- Default value: false + Determines if the helper has a value which can be used externally. The helper's `getValue` hook will be run whenever the value of the helper is accessed if this capability is enabled. -#### `isScheduledEffect` +#### `hasScheduledEffect` -Determines if the helper is scheduled. If enabled, the helper's `runEffect` hook -will run after render, and will not allow any type of state mutation when -running. +- Default value: false + +Determines if the helper has a scheduled effect. If enabled, the helper's +`runEffect` hook will run after render, and will not allow any type of state +mutation when running. ### Scheduled Helpers Timing @@ -318,9 +333,9 @@ The first time the object is looked up, the factory function will be called to create the helper manager. It will be cached, and in subsequent lookups the cached helper manager will be used instead. -Only one helper manager exists per helper factory, so many helpers will end up -using the same instance of the helper manager. As such, you should not store any -state on the helper manager that is related to a single helper instance. +Only one helper manager exists per helper definition, so many helpers will end +up using the same instance of the helper manager. As such, you should not store +any state on the helper manager that is related to a single helper instance. Helper managers must fulfill the following interface (This example uses [TypeScript interfaces](https://www.typescriptlang.org/docs/handbook/interfaces.html) @@ -348,7 +363,7 @@ import { capabilities } from '@ember/helper'; class MyHelperManager { capabilities = capabilities('3.21.0', { hasValue: true }); - + // ...snip... } ``` @@ -374,8 +389,8 @@ old versions until the next major version. capabilities('3.x'); ``` -The second argument is an optional object of capabilities and boolean values -indicating whether they are enabled or disabled. +The second argument is an object of capabilities and boolean values indicating +whether they are enabled or disabled. ```js capabilities('3.x', { @@ -406,7 +421,7 @@ If no value is specified, then the default value will be used. would result in less ability for the community to experiment and less flexibility if we chose to change helpers again in the future. -- The `isScheduledEffect` capability could be broken out into a separate RFC. +- The `hasScheduledEffect` capability could be broken out into a separate RFC. It is mostly separable, except for the impact it has on `getValue`. Value-less and effect-less helpers don't really make sense, so in isolation `getValue` would probably not be an optional hook, and the `hasValue` capability wouldn't @@ -505,3 +520,326 @@ export function helper(fn) { return fn; } ``` + +### Implementation of Ember Page Title using Effects + +This adapts the [ember-page-title](https://adopted-ember-addons.github.io/ember-page-title/) +addon to use the implementation proposed in this RFC. The biggest change to the +public API is that using `push` and `remove` directly schedules updates to the +title, so they can in theory be made public. The scheduling could also be moved +back to the helper itself to avoid that issue, this just cleans it up. + +```js +// ember-awesome-effects/addon/index.js +import { setHelperManager, capabilities } from '@ember/helper'; + +export class Effect { + constructor(owner, args) { + setOwner(this, owner); + this.args = args; + } + + setup() {} + update() {} + teardown() {} +} + +class EffectHelperManager { + capabilities = capabilities('3.21', { + hasScheduledEffect: true, + }); + + constructor(owner) { + this.owner = owner; + } + + createHelper(Definition, args) { + return { + instance: new Definition(this.owner, args), + didSetup: false + }; + } + + runEffect(bucket, args) { + let { instance, didSetup } = bucket; + instance.args = args; + + if (!didSetup) { + instance.setup(); + } else { + instance.update(); + } + } + + getDestroyable({ instance }) { + registerDestructor(instance, instance.teardown.bind(instance)); + + return instance; + } +} + +setHelperManager((owner) => new EffectHelperManager(owner), Effect.prototype); +``` + +```js +// ember-page-title/addon/services/page-title-list + +import Service, { inject as service } from '@ember/service'; +import { getOwner } from '@ember/application +import { scheduleOnce } from '@ember/run'; + +export default class PageTitleListService extends Service { + @service headData; + + tokens = []; + + /** + The default separator to use between tokens. + @property defaultSeparator + @default ' | ' + */ + defaultSeparator = ' | '; + + /** + The default prepend value to use. + @property defaultPrepend + @default true + */ + defaultPrepend = true; + + /** + The default replace value to use. + @property defaultReplace + @default null + */ + defaultReplace = null; + + constructor(owner) { + super(owner); + this._removeExistingTitleTag(); + + let config = getOwner(this).resolveRegistration('config:environment'); + if (config.pageTitle) { + ['separator', 'prepend', 'replace'].forEach((key) => { + if (isPresent(config.pageTitle[key])) { + set(this, `default${capitalize(key)}`, config.pageTitle[key]); + } + }); + } + } + + applyTokenDefaults(token) { + let { + defaultSeparator, + defaultPrepend, + defaultReplace, + } = this + + if (token.separator == null) { + token.separator = defaultSeparator; + } + + if (token.prepend == null && defaultPrepend != null) { + token.prepend = defaultPrepend; + } + + if (token.replace == null && defaultReplace != null) { + token.replace = defaultReplace; + } + } + + inheritFromPrevious(token) { + let { previous } = token; + + if (previous) { + if (token.separator == null) { + token.separator = previous.separator; + } + + if (token.prepend == null) { + token.prepend = previous.prepend; + } + } + } + + push(token) { + let { tokens } = this; + let tokenForIdIndex = tokens.findIndex(({ id }), token.id === id); + + if (tokenForIdIndex) { + let tokenForId = tokens[tokenForIdIndex]; + let { previous, next } = tokenForId; + + token.previous = previous; + token.next = next; + + this.inheritFromPrevious(token); + this.applyTokenDefaults(token); + + tokens.splice(tokenForIdIndex, 1, token); + return; + } + + let previous = tokens.slice(-1)[0]; + if (previous) { + token.previous = previous; + previous.next = token; + this.inheritFromPrevious(token); + } + + this.applyTokenDefaults(token); + + tokens.push(token); + + scheduleOnce('actions', this, this.updateTitle); + } + + remove(id) { + let { tokens } = this; + + let tokenIndex = tokens.findIndex(({ id }), token.id === id); + let token = tokens[tokenIndex]; + let { next, previous } = token; + + if (next) { + next.previous = previous; + } + + if (previous) { + previous.next = next; + } + + token.previous = token.next = null; + tokens.splice(tokenIndex, 1); + + scheduleOnce('actions', this, this.updateTitle); + } + + updateTitle() { + if (this.isDestroying || this.isDestroyed) return; + + this.headData.set('title', this.toString()); + } + + get visibleTokens() { + let { tokens } = this; + + let replaceIndex = tokens.length; + + while (replaceIndex--) { + if (tokens[replaceIndex].replace) { + break; + } + } + + return tokens.slice(replaceIndex - 1); + } + + get sortedTokens() { + let visible = this.visibleTokens; + let appending = true; + let group = []; + let groups = [group]; + let frontGroups = []; + visible.forEach((token) => { + if (token.front) { + frontGroups.unshift(token); + } else if (token.prepend) { + if (appending) { + appending = false; + group = []; + groups.push(group); + } + let lastToken = group[0]; + if (lastToken) { + token = copy(token); + token.separator = lastToken.separator; + } + group.unshift(token); + } else { + if (!appending) { + appending = true; + group = []; + groups.push(group); + } + group.push(token); + } + }); + + return frontGroups.concat( + groups.reduce((E, group) => E.concat(group), []) + ); + + + } + + toString() { + let tokens = this.sortedTokens; + + return tokens + .filter(token => Boolean(token.title)) + .map((token, index) => { + if (index + 1 < tokens.length) { + return token.title + token.separator; + } + + return token.title; + }) + .join(''); + }, + + /** + * Remove any existing title tags from the head. + * @private + */ + _removeExistingTitleTag() { + if (this._hasFastboot()) { + return; + } + + let titles = document.getElementsByTagName('title'); + for (let i = 0; i < titles.length; i++) { + let title = titles[i]; + title.parentNode.removeChild(title); + } + }, + + _hasFastboot() { + return !!getOwner(this).lookup('service:fastboot'); + } +} +``` + +```js +// ember-page-title/addons/helpers/title + +import Effect from 'ember-awesome-effects'; +import { inject as service } from '@ember/service'; + +export default class Title extends Effect { + @service pageTitleList; + + constructor(owner, args) { + super(owner, args); + + this.pageTitleList.push({ id: guidFor(this) }); + }, + + setup() { + this.update(); + } + + update() { + let token = assign({}, this.args.named, { + id: guidFor(this), + title: this.args.positional.join(''), + }); + + this.pageTitleList.push(token); + }, + + willDestroy() { + this.pageTitleList.remove(guidFor(this)); + } +} +``` From a412a5efd8d01c8106fb8f18501aa172bbc18b00 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Sat, 9 May 2020 14:18:30 -0700 Subject: [PATCH 5/9] Apply suggestions from code review Co-authored-by: Chris Krycho --- text/0625-helper-managers.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 0919c20329..9806ad5a95 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -585,7 +585,7 @@ setHelperManager((owner) => new EffectHelperManager(owner), Effect.prototype); // ember-page-title/addon/services/page-title-list import Service, { inject as service } from '@ember/service'; -import { getOwner } from '@ember/application +import { getOwner } from '@ember/application'; import { scheduleOnce } from '@ember/run'; export default class PageTitleListService extends Service { @@ -786,7 +786,7 @@ export default class PageTitleListService extends Service { return token.title; }) .join(''); - }, + } /** * Remove any existing title tags from the head. @@ -802,7 +802,7 @@ export default class PageTitleListService extends Service { let title = titles[i]; title.parentNode.removeChild(title); } - }, + } _hasFastboot() { return !!getOwner(this).lookup('service:fastboot'); @@ -823,7 +823,7 @@ export default class Title extends Effect { super(owner, args); this.pageTitleList.push({ id: guidFor(this) }); - }, + } setup() { this.update(); From b56a5e8b394268967c05d46622a9bf18679a8a38 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Sun, 10 May 2020 07:44:53 -0700 Subject: [PATCH 6/9] Update text/0625-helper-managers.md Co-authored-by: Robert Jackson --- text/0625-helper-managers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 9806ad5a95..751ba68179 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -578,7 +578,7 @@ class EffectHelperManager { } } -setHelperManager((owner) => new EffectHelperManager(owner), Effect.prototype); +setHelperManager((owner) => new EffectHelperManager(owner), Effect); ``` ```js From 99e0bf30934a31191abadbd6b725c4b395b7b70e Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Wed, 13 May 2020 19:34:09 -0700 Subject: [PATCH 7/9] respond to feedback --- text/0625-helper-managers.md | 71 +++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 751ba68179..3cc7137a90 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -11,6 +11,34 @@ Provides a low-level primitive for defining helpers. ## Motivation +Helpers are a valuable template construct in Ember. They have a number of +benefits that come from having their lifecycle being managed by the template and +container, including: + +1. Behavior can be _self-contained_. Some APIs need to run at multiple points in + time based on a template's lifecycle, such as for a plugin that needs to be setup + on initialization and torn down upon destruction. Doing this in components + via lifecycle hooks usually forces users to split their API across multiple + touch points in a component, which requires a lot of boilerplate and can make + it difficult to understand how the whole system works together. Helpers + provide a way for these shared concerns to be contained in a single location. + +2. It doesn't require _multiple inheritance_. Alternatives for sharing behaviors + that touch multiple parts of the lifecycle such as mixins and strategies like + them create complicated inheritance hierarchies that can be difficult to debug. + Helpers do not insert themselves into the inheritance hierarchy of a class, + they are children of the template instead, which is much easier to reason + about in practice. + +3. They are highly _composable_. Helpers, like components, can be used multiple + times in a template and can be used within `{{if}}` and `{{each}}` blocks. + Combined with their ability to be hold a self contained lifecycle, this makes + them a powerful tool for composing declarative behavior. + +4. They can be _destroyed_. They tie in naturally to the destruction APIs that + we have recently added to Ember, and that allows their lifecycle to be + managed in a self contained way. + Helpers are currently the only template construct in Ember that do not have a low-level primitive that is public. With components and modifiers, users can define component managers and modifier managers respectively to create their own high @@ -50,29 +78,7 @@ Addons such as [ember-page-title](https://github.com/adopted-ember-addons/ember- use this to make helpers that can be added to the template to specify _app behavior_ declaratively. This is a much better way to approach certain types of behavior and APIs, compared to the alternative of using mixins and -lifecycle hooks to manage them. Its benefits include: - -1. Behavior can be _self-contained_. Some APIs need to run at multiple points in time - based on a component's lifecycle, such as for a plugin that needs to be setup - on initialization and torn down upon destruction. Using lifecycle hooks for - this forces users to split their API across multiple touch points in a - component, which requires a lot of boilerplate and can make it difficult to - understand how the whole system works together. - -2. It doesn't require _multiple inheritance_. Mixins and strategies like them - create complicated inheritance hierarchies that can be difficult to debug. - A side-effecting helper does not insert itself into the inheritance - hierarchy, it is a child of the template instead, which is much easier to - reason about in practice. - -3. They are highly _composable_. Helpers, like components, can be used multiple - times in a template and can be used within `{{if}}` and `{{each}}` blocks. - Combined with their ability to be hold a self contained lifecycle, this makes - them a powerful tool for composing declarative behavior. - -4. They can be _destroyed_. They tie in naturally to the destruction APIs that - we have recently added to Ember, and that allows their lifecycle to be - managed in a self contained way. +lifecycle hooks to manage them. However, this pattern has some issues today, mostly stemming from the fact that they execute _during_ render, which is not an ideal time for side-effecting. @@ -212,9 +218,14 @@ This hook has the following timing semantics: `runEffect` is an optional hook that should run the effect that the helper is applying, setting it up or updating it. -This hook is scheduled to be called after render. The hook is autotracked, and -will rerun whenever any tracked values used inside of it are updated. Otherwise -it does not rerun. +This hook is scheduled to be called some time after render and prior to paint. +There is not a guaranteed, 1-to-1 relationship between a render pass and this +hook firing. For instance, multiple render passes could occur, and the hook may +only trigger once. It may also never trigger if it was dirtied in one render +pass and then destroyed in the next. + +The hook is autotracked, and will rerun whenever any tracked values used inside +of it are updated. Otherwise it does not rerun. The hook is also run during a time period where state mutations are _disabled_ in Ember. Any tracked state mutation will throw an error during this time, @@ -226,8 +237,10 @@ This hook is only called for helpers with the `hasScheduledEffect` capability enabled. It has the following timing semantics: **Always** -- called after the helper was first created -- called after autotracked state has changed +- called after the helper was first created, if the helper has not been + destroyed since creation +- called after autotracked state has changed, if the helper has not been + destroyed during render **Never** - called if the `hasScheduledEffect` capability is disabled @@ -355,7 +368,7 @@ interface HelperManager { } ``` -The capabilities property should be provided using the `capabilities()` function +The capabilities property _must_ be provided using the `capabilities()` function imported from the same module as `setHelperManager`: ```js From 23599fa2876798837cf3da937398ea82bc2926a9 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 14 May 2020 13:47:47 -0700 Subject: [PATCH 8/9] update helper manager instance wording --- text/0625-helper-managers.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 3cc7137a90..6c96c30c73 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -346,9 +346,12 @@ The first time the object is looked up, the factory function will be called to create the helper manager. It will be cached, and in subsequent lookups the cached helper manager will be used instead. -Only one helper manager exists per helper definition, so many helpers will end -up using the same instance of the helper manager. As such, you should not store -any state on the helper manager that is related to a single helper instance. +Only one helper manager is guaranteed to exist per helper definition, so many +helpers will end up using the same instance of the helper manager. As such, you +should not store any state on the helper manager that is related to a single +helper instance, or all of the instances of a class of helpers. In general, the +only state that should be stored on the manager directly is the `owner` that was +passed to it, so it can be passed on to the instances. Helper managers must fulfill the following interface (This example uses [TypeScript interfaces](https://www.typescriptlang.org/docs/handbook/interfaces.html) From e3569ff57a428ea515f1ab61363a8681686919fc Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 14 May 2020 14:46:45 -0700 Subject: [PATCH 9/9] add SSR semantics and update prose --- text/0625-helper-managers.md | 128 +++++++++++++++-------------------- 1 file changed, 56 insertions(+), 72 deletions(-) diff --git a/text/0625-helper-managers.md b/text/0625-helper-managers.md index 6c96c30c73..e6a3c9acac 100644 --- a/text/0625-helper-managers.md +++ b/text/0625-helper-managers.md @@ -178,11 +178,16 @@ This allows the manager to store metadata that it doesn't want to expose to the user. This hook is _not_ autotracked - changes to tracked values used within this hook -will _not_ result in a call to any of the other lifecycle hooks. If users do -want to autotrack some values used during construction, they can either create -the instance of the helper in `runEffect` or `getValue`, or they can use the -`cache` API to autotrack the `createHelper` hook themselves. This provides -maximum flexibility and expressiveness to manager authors. +will _not_ result in a call to any of the other lifecycle hooks. This is because +it is unclear what should happen if it invalidates, and rather than make a +decision at this point, the initial API is aiming to allow as much expressivity +as possible. This could change in the future with changes to capabilities and +their behaviors. + +If users do want to autotrack some values used during construction, they can +either create the instance of the helper in `runEffect` or `getValue`, or they +can use the `cache` API to autotrack the `createHelper` hook themselves. This +provides maximum flexibility and expressiveness to manager authors. This hook has the following timing semantics: @@ -234,7 +239,8 @@ to computed properties, etc. This is meant to prevent infinite rerenders and other antipatterns. This hook is only called for helpers with the `hasScheduledEffect` capability -enabled. It has the following timing semantics: +enabled. This hook is also not called in SSR currently, though this could be +added as a capability in the future. It has the following timing semantics: **Always** - called after the helper was first created, if the helper has not been @@ -244,6 +250,7 @@ enabled. It has the following timing semantics: **Never** - called if the `hasScheduledEffect` capability is disabled +- called in SSR #### `getDestroyable` @@ -324,7 +331,7 @@ snippets of TypeScript interfaces where appropriate. Sets the helper manager for an object or function. ```js -setHelperManager((owner) => new ClassHelperManager(owner), Helper.prototype) +setHelperManager((owner) => new ClassHelperManager(owner), Helper) ``` When a value is used as a helper in a template, the helper manager is looked up @@ -346,12 +353,13 @@ The first time the object is looked up, the factory function will be called to create the helper manager. It will be cached, and in subsequent lookups the cached helper manager will be used instead. -Only one helper manager is guaranteed to exist per helper definition, so many -helpers will end up using the same instance of the helper manager. As such, you -should not store any state on the helper manager that is related to a single -helper instance, or all of the instances of a class of helpers. In general, the -only state that should be stored on the manager directly is the `owner` that was -passed to it, so it can be passed on to the instances. +Only one helper manager is guaranteed to exist per `owner` and per usage of +`setHelperManager`, so many helpers will end up using the same instance of the +helper manager. As such, you should only store state that is related to the +manager itself. If you want to store state specific to a particular helper +definition, you should assign a unique helper manager to that helper. In +general, most managers should either be stateless, or only have the `owner` they +were created with as state. Helper managers must fulfill the following interface (This example uses [TypeScript interfaces](https://www.typescriptlang.org/docs/handbook/interfaces.html) @@ -545,58 +553,6 @@ public API is that using `push` and `remove` directly schedules updates to the title, so they can in theory be made public. The scheduling could also be moved back to the helper itself to avoid that issue, this just cleans it up. -```js -// ember-awesome-effects/addon/index.js -import { setHelperManager, capabilities } from '@ember/helper'; - -export class Effect { - constructor(owner, args) { - setOwner(this, owner); - this.args = args; - } - - setup() {} - update() {} - teardown() {} -} - -class EffectHelperManager { - capabilities = capabilities('3.21', { - hasScheduledEffect: true, - }); - - constructor(owner) { - this.owner = owner; - } - - createHelper(Definition, args) { - return { - instance: new Definition(this.owner, args), - didSetup: false - }; - } - - runEffect(bucket, args) { - let { instance, didSetup } = bucket; - instance.args = args; - - if (!didSetup) { - instance.setup(); - } else { - instance.update(); - } - } - - getDestroyable({ instance }) { - registerDestructor(instance, instance.teardown.bind(instance)); - - return instance; - } -} - -setHelperManager((owner) => new EffectHelperManager(owner), Effect); -``` - ```js // ember-page-title/addon/services/page-title-list @@ -829,10 +785,36 @@ export default class PageTitleListService extends Service { ```js // ember-page-title/addons/helpers/title -import Effect from 'ember-awesome-effects'; import { inject as service } from '@ember/service'; +import { setHelperManager, capabilities } from '@ember/helper'; + +class TitleHelperManager { + capabilities = capabilities('3.21', { + hasScheduledEffect: true, + }); + + constructor(owner) { + this.owner = owner; + } + + createHelper(Title, args) { + return new Title(this.owner, args); + } -export default class Title extends Effect { + runEffect(instance, args) { + instance.args = args; + instance.update(); + } + + getDestroyable({ instance }) { + registerDestructor(instance, () => instance.teardown()); + + return instance; + } +} + + +export default class Title { @service pageTitleList; constructor(owner, args) { @@ -841,10 +823,6 @@ export default class Title extends Effect { this.pageTitleList.push({ id: guidFor(this) }); } - setup() { - this.update(); - } - update() { let token = assign({}, this.args.named, { id: guidFor(this), @@ -854,8 +832,14 @@ export default class Title extends Effect { this.pageTitleList.push(token); }, - willDestroy() { + teardown() { this.pageTitleList.remove(guidFor(this)); } } + +setHelperManager((owner) => new EffectHelperManager(owner), Title); +``` + +```hbs +{{title "Blog"}} ```