Handlebars Strict Mode
Summary
In this RFC, we propose a set of changes to Ember's variant of Handlebars that are aimed at codifying best practices, improving clarity and simplifying the language. Together, these changes are bundled into a "strict mode" that Ember developers can opt-into. In contrast, the non-strict mode (i.e. what developers are using today) will be referred to as "non-strict mode" in this RFC.
This RFC aims to introduce and define the semantics of the Handlebars strict mode, as well as the low-level primitive APIs to enable it. However, it does not introduce any new user-facing syntax or conveniences for opting into this mode.
The intention is to unlock experimentation of features such as template imports and single-file components, but those features will require further design and iterations before they can be proposed and recommended to Ember users.
Motivation
Ember has been using Handlebars since it was released 7 years ago (!). Over
time, we have evolved, adapted and in some case repurposed the Handlebars
language significantly (remember "context-shifting" {{#each}}
?). This RFC
proposes to provide a "strict mode" opt-in to remedy some of Handlebars' design
decisions that we have come to regret over the years, or are otherwise not a
good fit for Ember. We believe this will make the Handlbars language easier to
learn, understand and implement, as well as enable better tooling to support
common development workflows for Ember developers.
We propose the following changes:
- No implicit globals
- No implicit
this
fallback - No implicit invocation of argument-less helpers
- No dynamic resolution
- No evals (no partials)
1. No implicit globals
Today, Ember implicitly introduces a set of implicit globals into a template's
scope, such as built-in helpers, components, modifiers. Apps and addons also
have the ability to introduce additional implicit globals by placing files into
the app
folder or broccoli tree. It is also possible to further influence
this behavior by using the intimate resolver API (such as the alternative
"pods" layout).
This adds a fair amount of dynamism, ambiguity and confusion when reading
templates. When an identifier is encountered, it's not always clear where this
value comes from or what kind of value it may be. This problem is especially
acute for the "ambigious content" position, i.e. <div>{{foo-bar}}</div>
,
which could be a local variable foo-bar
, a global component or a helper named
foo-bar
provided by the app or an addon, or {{this.foo-bar}}
(see the next
section). This problem is even worse when there is a custom resolver involved,
as the resolver may return a component or helper not found in the "expected"
location at runtime.
Not only is this confusing for the human reader, it also makes it difficult for
the Glimmer VM implementation as well as other ecosystem tooling. For example,
if the developer made a typo, {{food-bar}}
, it would be impossible to issue
a static error (build time error, inline error in IDEs) because the value may
be resolvable at runtime. It is also difficult, and in some cases impossible,
to implement IDE features such as "Jump to definition" without running code.
RFC #432 described some additional issues with the current implicit globals semantics.
We propose to remove support for implicit globals in strict mode. All values must be explicitly brought into scope, either through block params or defined in the "ambient scope" (see Detailed design section).
2. No implicit this
fallback
Today, when Ember sees a path like {{foo}}
in the template, after exhausting
the possibilities of implicit globals, it falls back to {{this.foo}}
. This
adds to the same confusion outlined above. More details about the motivation
can be found in the accepted RFC #308.
We propose to remove support for implicit this
fallback in strict mode. The
explicit form {{this.foo}}
must be used to refer to instance state, otherwise
it will trigger the same errors mentioned in the previous section.
It is worth mentioning that RFC #432 laid out a transition path towards a world where "everything is a value". Between this and the previous restriction, we essentially have completed that transition. To recap, here is a list of the outstanding issues:
- Not possible to reference globals outside of invocation positions
- Invocation of global helpers in angle bracket named arguments positions
- Naming collisions between global components, helpers and element modifiers
All of these problems are all related to implicit globals and/or implicit
this
fallback. Since neither of these features are supported in strict mode,
they are no longer a concern for us.
3. No implicit invocation of argument-less helpers
In the contextual helpers RFC, we discussed an issue regarding the invocation of arguments-less helpers in invocation argument positions.
In today's semantics, if there is a global helper named pi
, the following
template will result it the helper being invoked, and the result of the helper
invocation will be passed into the component.
<MyComponent @value={{pi}} />
This is not desirable behavior in the value-based semantics proposed in that RFC, because it makes it impossible to pass helpers around as values, just as it is possible to pass around contextual components today.
The contextual helper RFC proposed to deprecate this behavior and require mandatory parentheses to invoke the helper.
{{!-- passing the helper pi to the component --}}
<MyComponent @value={{pi}} />
{{!-- passing the result of invoking the helper pi to the component --}}
<MyComponent @value={{(pi)}} />
In strict mode, the current, soon-to-be-deprecated behavior will be removed, and the parentheses syntax will be mandatory.
Note that this only affects arguments-less helpers, which are exceedingly rare, as most helpers perform self-contained computations based on the provided arguments. It also only affect argument positions. In content and attribute positions, the intent is clear as it does not make sense to "pass a helper into the DOM". The parentheses-less form will continue to work in those positions, although the explicit parentheses are also permitted.
4. No dynamic resolution
Today, Ember supports passing strings to the component
helper (as well as the
helper
and modifier
helpers proposed in RFC #432).
This can either be passed as a literal {{component "foo-bar"}}
or passed as a
runtime value {{component this.someString}}
. In either case, Ember will
attempt to resolve the string as a component.
It shares some of the same problems with implicit globals (where did this come from?), but the dynamic form makes the problem more acute, as it is difficult or impossible to tell which components a given template is dependent on. As usual, if it is difficult for the human reader, the same is true for tools as well. Specifically, this is hostile to "tree shaking" and other forms of static (build time) dependency-graph analysis, since the dynamic form of the component helper can invoke any component available to the app.
We propose to remove support for these forms of dynamic resolutions in strict
mode. Specifically, passing a string to the component
helper (as well as the
helper
and modifier
helpers), whether as a literal or at runtime, will
result in an error.
In practice, it is almost always the case that these dynamic resolutions are switching between a small and bounded number of known components. For this purpose, they can be replaced by patterns similar to this.
import Component from '@glimmer/component';
import { First, Second, Third } from './contextual-components';
class ProviderComponent extends Component {
get selectedComponent() {
switch(this.args.selection) {
case "first":
return First;
case "second":
return Second;
case "third":
return Third;
}
}
}
{{yield this.selectedComponent}}
This will make it clear to the human reader and enable tools to perform optimizations, such as tree-shaking, by following the explict dependency graph.
5. No evals (no partials)
Ember currently supports the partial
feature. It takes a template name, which
can either be passed as a literal {{partial "foo-bar"}}
or passed as a
runtime value {{partial this.someString}}
. In either case, Ember will resolve
the template with the given name (with a prefix dash, like -foo-bar.hbs
) and
render its content as if they were copy and pasted into the same position.
In either case, the rendered partials have full access to anything that is "in
scope" in the original template. This includes any local variables, instance
variables (via implicit this
fallback or explicitly), implicit globals, named
arguments, blocks, etc.
This feature has all of the same problems as above, but worse. In addition to
the usual sources (globals, this
fallback etc), each variable found in a
partial template could also be coming from the "outer scope" from the caller
template. Conversely, on the caller side, "unused" variables may not be safe
to refactor away, because they may be consumed in a nested partial template.
Not only do these make it difficult for humans to follow, the same is true for tools as well. For example, linters cannot provide accurate undefined/unused variables warning. Whenever the Glimmer VM encounter partials, it has to emit a large amount of extra metadata just so they can be "wired up" correctly at runtime.
This feature has already been deprecated, and we propose to remove support for
partials completely in strict mode. Until the feature has been removed from
Ember itself, invoking the {{partial ...}}
keyword in strict mode will be a
static (build time) error.
The use case of extracting pieces of a template into smaller chunks can be replaced by template-only components. While this requires any variables to be passed explicitly as arguments, it also removes the ambiguity and confusions.
It should also be mentioned that the {{debugger}}
keyword also falls into the
category of "eval" in today's implementation, since it will be able to access
any variable available to the current scope, including this
fallback and when
nested inside a partial template. However, with the other changes proposed in
this RFC, we will be able to statically determine which variables the debugger
will have access to. Therefore we would still be able to support debugger usage
in strict mode without a very high performance penalty.
Detailed design
This RFC aims to introduce and define the semantics of the Handlebars strict mode, as well as the low-level primitive APIs to enable it. However, it does not introduce any new user-facing syntax or conveniences for opting into this mode.
The intention is to unlock experimentation of features such as template imports and single-file components, but those features will require further design and iterations before they can be proposed and recommended to Ember users.
During this phase of experimentation, these low-level APIs will also enable other build tools (such as ember-cli-htmlbars) to provide their own API for users to opt-in. In addition, once this RFC is approved and implemented in Ember, these low-level APIs will have the same semver guarentees as any other APIs in Ember, allow these experimentations to be built on stable grounds.
Low-level APIs
We propose to add an option to Ember's template compiler to enable strict mode compilation.
There are three primitive APIs involved in compiling Ember templates, precompile
,
template
and compile
.
The precompile
function (a.k.a. precompileTemplate
)
is responsible for taking a template string, running AST plugins, checking for
errors and returning the "wire format" representation of the template. The
exact details of this "wire format" is unspecified and changes from time to
time across minor Ember versions. The only guarantee is that it returns a
string whose content is a valid JavaScript expression.
For example:
import { precompileTemplate } from '@ember/template-compilation';
precompileTemplate('Hello, {{name}}!', {
moduleName: 'hello.hbs'
}); /* => `{
"id": "AoL2bkKU",
"block": "{\"statements\":[\"...\"]}",
"meta": {"moduleName":"hello.hbs"}
}` */
Again, the exact wire format changes from time to time, but the key is that the content is valid JavaScript. This allows build tools to take this output and insert it into any context where JavaScript expressions are allowed.
At runtime, the "wire format" can be "rehydrated" into something consumable by
Ember via the template
function (a.k.a. createTemplateFactory
).
Build tools typically compile templates into JavaScript modules by combining
these two pieces. In our example, the hello.hbs
template is typically
compiled into a module similar to this:
import { createTemplateFactory } from '@ember/template-factory';
export default createTemplateFactory({
"id": "AoL2bkKU",
"block": "{\"statements\":[\"...\"]}",
"meta": { "moduleName": "hello.hbs" }
});
Finally, the compile
function (a.k.a. compileTemplate
)
is a convenience helper that simply combines the two steps by taking a raw
template string and returning a ready-to-be-consumed template object (the
output of createTemplateFactory
), instead of the wire format. This is
mostly used for compiling templates at runtime, which is pretty rare.
We propose to introduce a new strictMode
option to the precompile
and compile
functions to enable strict mode compilation:
import { precompileTemplate } from '@ember/template-compilation';
precompileTemplate('Hello, {{name}}!', {
moduleName: 'hello.hbs',
strictMode: true
});
The ambient scope
Since there are no implicit globals in strict mode, there has to be an alternative mechanism to introduce helpers and components into scope.
Whenever the strict mode compiler encounters an undefined reference, i.e. an identifier that is not a currently in-scope local variable (block param), the default behavior is to assume that these are references to variables in the ambient scope. That is, the compiler will emit JavaScript code that contains JavaScript references to these variables.
For example, consider the following template:
{{#let this.session.currentUser as |user|}}
<BlogPost @title={{titleize @model.title}} @body={{@model.body}} @author={{user}} />
{{/let}}
Here, this.session.currentUser
is an explicit reference to the component's
instance state, user
is a local variable introduced by the #let
helper and
@model
is a reference to a named argument. They all have obvious semantics.
On the other hand, BlogPost
and titleize
are undefined references. The
compiler will assume that they are defined in the surrounding ambient scope
at runtime and produce an output like this:
import { precompileTemplate } from '@ember/template-compilation';
precompileTemplate(`{{#let this.session.currentUser as |user|}}
<BlogPost @title={{titleize @model.title}} @body={{@model.body}} @author={{user}} />
{{/let}}`, {
moduleName: 'index.hbs',
strictMode: true
}); /* => `{
"id": "ANJ73B7b",
"block": "{\"statements\":[\"...\"]}",
"meta": { "moduleName": "index.hbs" },
"scope": () => [BlogPost, titleize],
"isStrictMode": true
}` */
Again, the specific format here is unimportant and subject to change. The key
here is that the JavaScript code produced by the compiler contains references
(via the scope
closure in this hypothetical compilation) to the JavaScript
variables BlogPost
and titleize
in the surrounding JavaScript scope.
The build tool is responsible for "linking" these undefined references by
putting the compiled JavaScript code inside a JavaScript context where these
variables are defined. Otherwise, depending on the configuration, the undefined
references will either cause a static (build-time) error from the linter,
transpiler (e.g. babel) or packager (e.g. rollup or webpack), or a runtime
ReferenceError
when the code is evaluated by a JavaScript engine.
This low-level, primitive feature is mainly useful for building user-facing template import and single-file component features. While this RFC does not propose a user-facing syntax for these features, here is a hypothetical template import syntax for the illustrative purposes only:
---
import { titleize } from '@ember/template-helpers';
import BlogPost from './components/blog-post';
---
{{#let this.session.currentUser as |user|}}
<BlogPost @title={{titleize @model.title}} @body={{@model.body}} @author={{user}} />
{{/let}}
The build tool can compile this into a JavaScript module like this:
import { createTemplateFactory } from '@ember/template-factory';
import { titleize } from '@ember/template-helpers';
import BlogPost from './components/blog-post';
export default createTemplateFactory({
"id": "ANJ73B7b",
"block": "{\"statements\":[\"...\"]}",
"meta": { "moduleName": "index.hbs" },
"scope": () => [BlogPost, titleize],
"isStrictMode": true
});
When this is evaulated by a JavaScript engine, the references in the scope
closure will automatically be "linked up" with the imports, and Ember will be
able to reference these values when rendering the template. Note that these
references are static–the values are essentially "snapshotted" by the
rendering engine whenever the template is instantiated. Updates to these values
in the JavaScript scope will not be observable by the rendering engine, even
in conjunction with Ember.set
or @tracked
.
Optionally, the build tool can choose to restrict the set of allowed ambient references by suppling an array of available identifiers to the compiler:
import { precompileTemplate } from '@ember/template-compilation';
precompileTemplate(`{{#let this.session.currentUser as |user|}}
<BlogPost @title={{titleize @model.title}} @body={{@model.body}} @author={{user}} />
{{/let}}`, {
moduleName: 'index.hbs',
strict: true,
scope: ['BlogPost', 'titleize'],
isStrictMode: true
});
If the template compiler encounters any undefined references outside of this allowed list, it will throw an error with the appropiate location info. It also follows that build tools can choose to disable this feature completely by passing an empty array.
Keywords
While most items should be imported into scope explicitly, some of the existing constructs in the language are unimportable will be made available as keywords instead:
action
debugger
each-in
each
has-block-params
has-block
hasBlock
if
in-element
let
link-to
(non-block form curly invocations)loc
log
mount
mut
outlet
query-params
readonly
unbound
unless
with
yield
These keywords do not have to be imported into scope and will always be ambiently available.
On the other hand, the following built-in constructs will need to be imported (the current or proposed import paths in parentheses):
array
(import { array } from '@ember/helper
)concat
(import { concat } from '@ember/helper
)fn
(import { fn } from '@ember/helper
)get
(import { get } from '@ember/helper
)hash
(import { hash } from '@ember/helper
)on
(import { on } from '@ember/modifier'
)Input
(import { Input } from '@ember/component
)LinkTo
(import { LinkTo } from '@ember/routing
)Textarea
(import { Textarea } from '@ember/component'
)
In general, built-ins that can be made importable should be imported. The main difference are that some of the keywords uses internal language features (e.g. implemented via AST transforms) that requires them to be keywords.
Some of the keywords included in the list are considered legacy, and may be deprecated in the future via future RFCs. If that happens before strict mode becomes available in a stable release of Ember, those RFC may propose to drop support for the legacy keywords in strict mode altogether.
Deprecations
The following features should be deprecated and removed in non-strict mode:
- Implicit
this
fallback, proposed in RFC #308 - Implicit invocation of argument-less helpers, proposed in RFC #432
- Partials, proposed in RFC #449
When all of these features are removed, the main difference between non-strict mode and strict mode will be the precense of globals and the ability to perform dynamic runtime resolutions.
How we teach this
Strict mode is intended to become the main way Ember developers author templates going forward. We anticipate this is going to be a slow transition, but once the majority of Ember developers have migrated, we expect them to find it clearer, more intuitive and more productive.
Three of the strict mode restrictions—no implicit this
fallback, no implicit
invocation of argument-less helpers and no eval—were already proposed in their
respective RFCs. These features will be deprecated in non-strict mode templates and
can be fixed incrementally. We should continue implementing these deprecatios
as already proposed and encouraged adoption. It is quite likely that by the
time strict mode becomes widely available, these deprecations will have already
been implemented and fixed in most Ember applications.
On the other hand, implicit globals and dynamic resolutions are not going away anytime soon in non-strict mode. These features are intrinsically tied to non-strict mode, and we expect developers to migrate to template imports or single-file components when those over time, which would also opt them into strict mode, when those features become available.
That being said, this RFC does not propose any user-facing changes and there will be no official, application-level opt-in to strict mode until either template imports or single-file components exit the experimental phase and become adopted as official feature in Ember via a future RFC.
Therefore, we do not recommend making any changes to the guides to document the strict mode semantics at this stage. Instead, the guides should be updated to feature template imports or single-file components when they become available.
As for the low-level APIs, we should update the API documentation to cover the
new flags (strictMode
and scope
). The documentation should cover the details of
the "ambient scope" feature discussed in this RFC, and emphasize that it is
intended for linking static values such as helpers and components.
Drawbacks
We could just deprecate without removing this
fallback and partials, and let
implicit globals and dynamic resolution co-exist with template imports (the
primary consumer of the proposed strict mode). However, this will create a very
confusing compromise and users will not get most of the benefits of having
template imports in the first place. We will also lose out on the opportunity
to improve on the static guarantees in order to build better tools. Leaving
around implicit globals also has the issues
discussed in the contextual helpers RFC.
Alternatives
Instead of bundling these into a single "strict mode" opt-in, we could allow developers to opt-in to each of these restrictions individually.
In addition to the teaching and discoverability problems, we will also need to build additional tooling and configuration mechanism (
handlebars.json
?) for this.By adopting these piecemeal, we will also have to define the interaction and combined semantics for any possible combinations of these flags, and tooling will be unable to take advantage of the improved static guarantees without doing a lot of work to account for all these possibilities.
Instead of proposing a standalone strict mode, we could just bundle these semantics into the templates imports proposal.
That would make it a very long and complex RFC. In addition, other build tools like ember-cli-htmlbars-inline-precompile will not be able to adopt the same semantics.
Switch to HTML attributes by default in strict mode.
Today, Glimmer uses a complicated set of heuristics to decide if a bound HTML "attribute" syntax should indeed be set using
setAttribute
or set as a JavaScript property usingelement[...] = ...;
. This does not always work well in practice, and it causes a lot of confusion and complexity.We intend to move to an "attributes syntax always mean attributes" (and use modifiers for the rare cases of setting properties). We briefly considered grouping that change into the strict mode opt-in, but ultimately decided it would be too confusing for strict mode to include such a change. It's better to deprecate the feature and make this an app-wide setting.
Fix
(action ...)
binding semantics in strict mode.Similarly, there are some not ideal semantics issues with
(action ...)
around how the function'sthis
is bound. We similarly considered fixing it in strict mode but ultimately decided it wouldn't be appropriate.
Unresolved questions
None