Helper Managers
Summary
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:
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.
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.
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.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
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, hasScheduledEffect
.
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:
// app/helpers/title.js
export default helper(([title]) => {
window.document.title = title;
});
{{title "My Document Title"}}
Addons such as 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.
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 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.
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.
// object is used here to mean any valid WeakMap key
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.
interface HelperCapabilitiesOptions {
hasValue?: boolean;
hasDestroyable?: boolean;
hasScheduledEffect?: boolean;
}
type HelperCapabilities = Opaque;
export declare function capabilities(
// to be replaced with the version of Ember this lands in
version: '3.21.0',
options: HelperCapabilitiesOptions
): HelperCapabilities;
Helper managers themselves have the following interface:
interface HelperManager<HelperStateBucket> {
capabilities: HelperCapabilities;
createHelper(definition: HelperDefinition, args: TemplateArgs): HelperStateBucket;
getValue?(bucket: HelperStateBucket): unknown;
runEffect?(bucket: HelperStateBucket): 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:
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 is not autotracked - changes to tracked values used within this hook 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:
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 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,
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 hasScheduledEffect
capability
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 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 - called in SSR
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
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
hasValue
hasScheduledEffect
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.
hasScheduledEffect
- 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
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.
setHelperManager((owner) => new ClassHelperManager(owner), Helper)
When a value is used as a helper in a template, the helper manager is looked up 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:
- A factory function, which receives the
owner
and returns an instance of a helper manager. - 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 the cached helper manager will be used instead.
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 for precision, you do not need to write helper managers using TypeScript):
interface HelperManager<HelperStateBucket> {
capabilities: HelperCapabilities;
createHelper(definition: HelperDefinition, args: TemplateArgs): HelperStateBucket;
getValue?(bucket: HelperStateBucket): unknown;
runEffect?(bucket: HelperStateBucket): void;
getDestroyable?(bucket: HelperStateBucket): object;
}
The capabilities property must be provided using the capabilities()
function
imported from the same module as setHelperManager
:
import { capabilities } from '@ember/helper';
class MyHelperManager {
capabilities = capabilities('3.21.0', { hasValue: true });
// ...snip...
}
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.
capabilities('3.x');
The second argument is an object of capabilities and boolean values indicating whether they are enabled or disabled.
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
hasScheduledEffect
capability could be broken out into a separate RFC. It is mostly separable, except for the impact it has ongetValue
. Value-less and effect-less helpers don't really make sense, so in isolationgetValue
would probably not be an optional hook, and thehasValue
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:
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, args) {
let helper = Definition.create(this.ownerInjection);
return { helper, args };
}
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);
import { tracked } from '@glimmer/tracking';
import { setHelperManager, capabilities } from '@ember/helper';
class FunctionalHelperManager {
capabilities = capabilities({
hasValue: true,
});
createHelper(fn, args) {
return { fn, args };
}
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;
}
Implementation of Ember Page Title using Effects
This adapts the 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.
// 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');
}
}
// ember-page-title/addons/helpers/title
import { setOwner } from '@ember/application';
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);
}
runEffect(instance) {
instance.update();
}
getDestroyable({ instance }) {
registerDestructor(instance, () => instance.teardown());
return instance;
}
}
export default class Title {
@service pageTitleList;
constructor(owner, args) {
setOwner(this, owner);
this.args = args;
this.pageTitleList.push({ id: guidFor(this) });
}
update() {
let token = assign({}, this.args.named, {
id: guidFor(this),
title: this.args.positional.join(''),
});
this.pageTitleList.push(token);
},
teardown() {
this.pageTitleList.remove(guidFor(this));
}
}
setHelperManager((owner) => new EffectHelperManager(owner), Title);
{{title "Blog"}}