Start Date Release Date Release Versions PR link Tracking Link Stage Teams
5/17/2021 5/13/2022
  • ember-source: v4.5.0
Recommended
  • Framework
  • Learning

Default Helper Manager

Summary

Anything that can be in a template has its lifecycle managed by the manager pattern. Today, we have known managers for @glimmer/component, @ember/helper, etc. But what happens when the VM encounters an object for which there is no manager?, such as a plain function? This RFC proposes a default behavior for those unknown scenarios when it comes to helpers.

Motivation

The addon, ember-could-get-used-to-this demonstrated that it's possible to use plain functions for helpers and modifiers. And since Ember 3.25, helpers can be invoked directly from value references, this opened a whole new world of ergonomics improvements where a dev could define a function in a component class and use that function as a helper, thanks to ember-could-get-used-to-this implementing a Helper Manager that knew what to do with plain functions.

This has the impact of greatly reducing mental overhead around helpers for app and addon authors, in that, folks no longer need to jump over to the app/addon helpers directory to create a helper "just to do this one simple thing". It's all now {{this.myHelper}} or {{this.myModifier}}.

The introduction of a plain-function helper-manager is important because over the past several years, we've seen on numerous occasion, folks new to Ember inherently expect that plain functions work in templates.

Example:

import Component from '@glimmer/component';
import { setComponentTemplate } from '@ember/component';
import { hbs } from 'ember-cli-htmlbars';

export default class Example extends Component {
  double = num => num * 2;
}
{{this.double 2}} => prints 4
<SomeComponent @foo={{this.double 2}} /> => @foo === 4

A default modifier manager will be covered in a different RFC.

Detailed design

A Default Manager is not something that can be chosen by the user, but is baked in to the framework as a default so that a user doesn't have to build something to use a non-framework-specific variant of the three constructs: Helpers, Modifiers, and Components.

The desired usage of a plain function in a template should be:

  • convenient
  • reduce boilerplate
  • be easily portable to JS for developers' mental model of how template and JS interact
  • support normal JavaScript idioms and existing JavaScript functions (from lodash, etc).

Which results in:

  • default to positional parameters
  • all named arguments are grouped into an "options object" as the last parameter. this happens to align with the syntax of helper invocation where named arguments may not appear before the last positional argument.
  • if no named arguments are passed in the template, no "options object" is passed to the JS call.

Example with mixed params

{{this.calculate 1 2 op="add"}}

would be an example invocation of a function with the following signature expressed as TypeScript for clarity:

type Options = { op: 'add' | 'subtract' }
class A {
  calculate(first: number, second: number, options: Options) {
    // ...
  }
}

for unknown amounts of parameters, the typescript can be awkward,

but there is a TC39 proposal: proposal-deiter that could make destructuring simpler and inlined to

  calculate(...numbers: number[], options: Options) {

Example with only positional parameters

{{this.add 1 2 3 4}}

Because there are no named arguments passed in, the method signature can be simple:

class A {
  add(...numbers: number[]) {
    // ...
  }
}

Example with consuming tracked data defined outside of the helper

This works with the helper and Helper from @ember/component/helper, as well as plain functions.

<output>{{this.multiply 4}}</output>

<button {{on 'click' this.increment}}>Increment</button>
class A {
  @tracked multiplicand = 5;

  multiply = (passed) => passed * this.multiplicand;

  increment = () => this.multiplicand++;
}

When the button is clicked, the text in <output> will update, even though the multiplicand is not passed to the helper.

Example Default Helper Implementation

The implementation for the this function-handling helper-manager could look like this:

import {
  setHelperManager,
  capabilities as helperCapabilities,
} from '@ember/helper';
import { assert } from '@ember/debug';

class FunctionHelperManager {
  capabilities = helperCapabilities('3.23', {
    hasValue: true,
  });

  createHelper(fn, args) {
    return { fn, args };
  }

  getValue({ fn, args }) {
    let argsForFn = args.positional;

    if (Object.keys(args.named).length > 0) {
      argsForFn.push(args.named);
    }

    return fn(...argsForFn);
  }

  getDebugName(fn) {
    return fn.name || '(anonymous function)';
  }
}

const DEFAULT_HELPER_MANAGER = new FunctionHelperManager();

// side-effect -- this file needs to be imported for the helper manager to be installed
setHelperManager(() => DEFAULT_HELPER_MANAGER, Function.prototype);
  • when the "helper" is created, the function is not invoked
  • when getValue is invoked,
  • the function is invoked with the named arguments all grouped into an object in the last arg ("options object")
  • ~~if no named arguments are given, an empty object is used instead to allow less nullish checking in userland~~ (see notes below)
  • if no named are passed, the "options object" argument is omitted
  • to register this helper manager, it should occur during app boot so developers do not need to import anything to trigger the setHelperManager call
Notes regarding the "options object" argument

An earlier version of this RFC initially proposed an "options object" with always be passed as the argument, even when no-named arguments are passed. During implementation this was observed to be problematic.

For instance, given the following JavaScript function:

function sum(...values) {
  let total = 0;

  for (let value of values) {
    total += value;
  }

  return total;
}

In the original proposal, an invocation like {{sum 1 2 3}} would result in the JS call sum(1, 2, 3, {}), which would yield surprising and incorrect result.

Another case where this matters is with default arguments:

function formatDate(date, formatString = "DD MM YYYY hh:mm:ss") {
  return ...;
}

In the original proposal, an invocation like {{formatDate this.now}} would result in the JS call formatDate(this.now, {}), which has the effect of overriding the default argument formatString with an empty object, also leading to surprising and incorrect behavior.

Given the goal of the RFC is to support normal JavaScript idioms and the ability to use a large variety of existing JavaScript functions (from packages like lodash) directly in the template, the proposal is updated to only pass the "options object" when necessary.

This has the drawback of an arguably less consistent signature. However, in practice, this did not appear to be a issue. Base on the semantics and idioms of JavaScript, it is quite rare for functions to mix-and-match variable positional arguments or defaulting of positional arguments together with "named arguments" ("option object") in a way that would conflict with what the updated proposal. Notably, JavaScript does not support myFunc(...args, options) in the syntax, and functions with these kind of signatures already needs to manually introspect the arguments carefully, in ways that should be compatible with the current proposal.

Updating highlevel manager choosing algorithm

This is the existing manager chooser algorithm, but with extra additions required by this RFC (notated by -->).

  • if inside element space
    • use getModifierManager
  • if inside document body space using curly invocation
    1. attempt lookup via getComponentManager and invoke it
    2. attempt lookup via getHelperManager and invoke it
    3. --> if function, fallback to this RFC's default manager
    4. render, e.g.: [Object object], for objects
  • if inside document body space using angle invocation
    • attempt to lookup via getComponentManager and invoke it
  • if inside of a subexpression's "head" (e.g. PathExpression) position
    1. attempt to lookup via getHelperManager and invoke it
    2. --> if function, fallback to this RFC's default manager
    3. error
  • if inside of a subexpression arguments
    • pass the value

How a template syntax plays in to this behavior

In the current state of templates there is some ambiguity in syntax around helper/component invocation invocation. Below is a list exploring the various syntaxes and how the code implemented in the framework for this RFC will react to various passed value/function/etc types. All of this is current behavior and this RFC is not proposing a syntax change. In template strict mode, there is no ambiguity to worry about.

  • {{val}}
  • typeof val === 'function': Helper, invoked
    • presently, it's possible to have curly components use this syntax as well. By defining this as a helper, there is a possibility of confusion as the manager choosing algorithm will select a component before it selects a helper when using curlies.
    • because angle-bracket invocation is the generally accepted way to invoke a component, this may be an acceptable trade-off
  • typeof val === 'object': Value, rendered
  • val instanceof AnyClass: Value, rendered
    • Today, classes are .toString()'d, but it's feasible that one could define their own custom helper manager or component manager to do something different.
  • {{ (val) }}
  • typeof val === 'function': Helper, existing behavior
  • typeof val === 'object': Expected Helper error, no manager found
  • val instanceof AnyClass: Expected Helper, no manager found
  • {{val arg}}
  • typeof val === 'function': Helper, existing behavior
    • Similar to {{val}}, it is possible today to define a component manager that takes positional args that would be invoked with this syntax.
  • typeof val === 'object': Expected Helper error
  • val instanceof AnyClass: Expected Helper, no manager found
  • <Component @arg={{val arg}} />
  • typeof val === 'function': Helper, existing behavior
  • typeof val === 'object': Expected Helper error
  • val instanceof AnyClass: Expected Helper, no manager found
  • <Component @arg={{val}} />
  • typeof val === 'function': Value
    • if this were to evaluate as a helper it would break existing behavior where you may be passing an event handler to the component
    • another consequence of evaluating val as a helper and invoking it is that it would be more likely to cause an infinite revalidation assertion, which at present, would be hard to track down the source of, but may be an option if the VM one day has an equivalent to React's ErrorBoundary
    • if someone wanted to invoke val as a helper when passed as an argument, they would need to add surrounding (), example: <Component @arg={{ (val) }} />
  • typeof val === 'object': Passed as argument to the component
  • val instanceof AnyClass: Passed as argument to the component
  • <Component @arg={{val 1}} />
  • typeof val === 'function': Helper, existing behavior
    • another consequence of evaluating val as a helper and invoking it is that it would be more likely to cause an infinite revalidation assertion, which at present, would be hard to track down the source of, but may be an option if the VM one day has an equivalent to React's ErrorBoundary
  • typeof val === 'object': Expected Helper error
  • val instanceof AnyClass: Expected Helper, no manager found
  • <Component @arg={{ (val 1) }} />
  • typeof val === 'function': Helper, existing behavior
  • typeof val === 'object': Expected Helper error
  • val instanceof AnyClass: Expected Helper, no manager found

How we teach this

On the Helper Functions page, We'll want to insert a section early on about how helpers can be "local" to or defined on components and controllers. Then, once there have been examples of the local/private helper, the existing content can continue to talk about the "global helper" -- explicitly differentiating between the local/private helper, rather then retaining the general "Helper Function" title as "Helper Functions" are all of these, rather than just the globally defines ones.

Existing users of Ember should be made aware of these capabilities, once implemented, via the release blog post, with some examples -- but folks watching developments in the ecosystem will likely be aware of ember-could-get-used-to-this, which implement some parts of this RFC, so the migration path for users of ember-could-get-used-to-this should be straight-forward. That addon may want to deprecate their similar functionality after the proposed behavior here lands -- though, this RFC suggests implementation such that developers may still use ember-could-get-used-to-this without disruption.

Drawbacks

There would no longer be a possibility of using functions as components when invoked with curlies. Angle bracket function components would still be possible.

There could be some awkwardness around the last argument passed to the function, as the type signature may not match how the call-site expects the signature to be. Projects like Glint would be essential for helping with clarity here.

The difference between <Component @handler={{this.handler}} /> and <Component @handler={{this.handler 1}} /> could awkward for folks, as the syntax says that a value always passes a value, unless there are arguments added within the curly braces, in which this.handler is invoked and the return value is instead passed as @handler. This could lead to infinite revalidation assertions, which, without an ErrorBoundary (from React) and Error messages that show the location in the template where an error originates from, would be fairly hard to track down. The problem goes away entirely in template strict mode, so it may not be something we want to worry about in the short-term (outside of documenting the possibility and what to do when the situation occurs)

Alternatives

Class-based default helpers would allow greater flexibility for creating helpers, but would also perpetuate the current problem of most surprise when trying to invoke functions from defined outside of ember within templates (such as XState's state.matches function).

Unresolved questions

TBD