diff --git a/text/000-sfc-and-template-import-primitives.md b/text/000-sfc-and-template-import-primitives.md new file mode 100644 index 0000000000..2943306cb0 --- /dev/null +++ b/text/000-sfc-and-template-import-primitives.md @@ -0,0 +1,875 @@ +- Start Date: 2019-02-22 +- Relevant Team(s): Ember.js +- RFC PR: https://github.com/emberjs/rfcs/pull/454 +- Tracking: (leave this empty) + +# SFC & Template Import Primitives + +## Summary + +Expose low-level primitives for embedding templates in JavaScript and +associating templates with component classes. + +These primitives unlock experimentation, allowing addons to provide +highly-requested features (such as single-file components) via stable, public +API. + +## Motivation + +This proposal is intended to unlock experimentation around two +highly-requested features: + +1. Single-file components. +2. Module imports in component templates. + +Although exploring these features is the primary motivation, an important +benefit of stabilizing low-level APIs is that they enable the Ember community +to experiment with new, unexpected ideas. + +### Single-File Components + +In Ember components today, JavaScript code lives in a `.js` file and template +code lives in a separate `.hbs` file. Juggling between these two files adds +friction to the developer experience. + +Template and JavaScript code are inherently coupled, and changes to one are +often accompanied by changes to the other. Separating them provides little +value in terms of improving reusability or composability. + +Other component APIs eliminate this friction in different ways. React uses +JSX, which produces JavaScript values: + +```js +// MyComponent.jsx + +export default class extends Component { + state = { name: 'World' }; + + render() { + return
Hello {this.state.name}!
+ } +} +``` + +Vue has an optional [`.vue` single-file component (SFC) +format](https://vuejs.org/v2/guide/single-file-components.html): + +```html + + + + + +``` + +### Module Imports in Templates + +One benefit of JSX is that it leverages JavaScript's existing scope +system. React components are JavaScript values that can be imported and +referenced like any other JavaScript value. If a binding would be in scope +in JavaScript, it's in scope in JSX: + +```js +import { Component } from 'react'; +import OtherComponent from './OtherComponent'; + +export default class extends Component { + render() { + return ( +
+ // We know OtherComponent is in scope because it was imported at the top + // of the file, so we can invoke it here as a component. + +
+ ); + } +} +``` + +This is a bit nicer than Vue's SFCs, where JavaScript code and template code +happen to be in the same file but otherwise interact no differently than if +they were still in separate files. Developers must learn a proprietary Vue +API for explicitly copying values from JavaScript's scope into the template's +scope, and component names can be different between where they are imported +(`OtherComponent`) and where they are invoked (`other-component`): + +```html + + + +``` + +Neither of these approaches is exactly right for Ember. Ideally, we'd find a +way to "bend the curve" of tradeoffs: maintaining the performance benefits of +templates, while gaining productivity and learnability by having those +templates seamlessly participate in JavaScript's scoping rules. + +### Unlocking Experimentation + +Rather than deciding on the best syntax for single-file components and +template imports upfront, this RFC proposes new low-level APIs that addons +can use as compile targets to implement experimental file formats. + +For example, imagine a file format that uses a frontmatter-like syntax to +combine template and JavaScript sections in a single file: + +```hbs +--- +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; + +export default class MyComponent extends Component { + @service session; +} +--- + +{{#let this.session.currentUser as |user|}} + +{{/let}} +``` + +Or imagine this syntax, where a component's template is located syntactically +within the class body: + +```js +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; + +export default class MyComponent extends Component { + + + @service session; +} +``` + +Or even a variation of the Vue SFC format: + +```html + + + +``` + +With the API proposed in this RFC, an Ember addon could transform any of the +above file formats, at build time, into a JavaScript file that looks +something like this: + +```js +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; + +import { __template__, setComponentTemplate } from '@ember/template-factory'; + +const template$1 = __template__({ + source: `{{#let this.session.currentUser as |user|}}\n `; +const embeddableTemplate = createEmbeddableTemplate(templateSource); + +const jsFile = ` +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; +import { setComponentTemplate } from '@ember/template-factory'; +${embeddableTemplate.prelude}; + +const template$1 = ${embeddableTemplate.value}; + +export default setComponentTemplate(template$1, class MyComponent extends Component { + @service session; +}); +`; +``` + +The return value of `createEmbeddableTemplate` is an object with two +properties, `prelude` and `value`. + +The `prelude` property is a string containing setup code, such as `import` statements, +and must be inserted in the top level of the module. If multiple templates +are being embedded in the same JavaScript module, the prelude must be +included only once. + +The `value` property is a string containing a JavaScript expression +representing the template source and additional metadata. This value will be +transformed into the final wire format version of the template when the +application is compiled. + +The proposed syntax for templates encoded in JavaScript is described in the +[Embeddable Template Format](#embeddable-template-format) section. However, +the exact JavaScript code returned from `createEmbeddableTemplate` should be +considered opaque and may change over time. + +The `createEmbeddableTemplate` function will throw an exception if the +template passed to it contains a syntax error. + +### Associating Templates and Classes + +In Ember, a component is made up of a template and an optional backing +JavaScript class. But how does Ember know which class and template go +together? + +Today, the answer is "it's complicated." That complexity makes it difficult +for addons to override default behavior and experiment with things like +single-file components. + +By default, templates are associated with a class implicitly by name. For +example, the template at `app/templates/components/user-avatar.hbs` will be +joined with the class exported from `app/components/user-avatar.js`, because +they share the base file name `user-avatar`. + +The benefit of this approach is that files are in predictable locations. If +you are editing a template and need to switch to the class, or are reading a +another template and want to see the implementation of the `{{user-avatar}}` +component, finding the right file to jump to should not be difficult. + +Sometimes, though, Ember developers run into scenarios where they'd like to +re-use the same template across multiple components. Ember supports +overriding a component's template by defining the `layout` property in the +component class: + +```js +import Component from '@ember/component'; +import OtherTemplate from 'app/templates/other-template'; + +export default Component.extend({ + layout: OtherTemplate +}) +``` + +However, this capability comes at a cost. Supporting dynamic layouts like +this requires Ember to put the Glimmer VM into a mode where it checks each +component instance for a `layout` property, preventing these components from +being fully optimized. + +This RFC proposes a more explicit, static system for associating templates +with component classes. A function called `setComponentTemplate` is exported +from the `@ember/template-factory` package. To create the association, this +function is invoked with the embedded template and component class as +arguments: + +```js +import Component from '@glimmer/component'; +import { setComponentTemplate } from '@ember/template-factory'; + +const template = /* embeddable template here */ + +class MyComponent extends Component { + /* ... */ +} + +setComponentTemplate(template, MyComponent); +``` + +For convenience, `setComponentTemplate` returns the same component class that +was passed, allowing the class to be defined and associated with a template +in a single expression: + +```js +export default setComponentTemplate(template, class MyComponent extends Component { + /* ... */ +}); +``` + +Even though this is a runtime API, template association is intended to be as +static and restricted as possible, allowing us to enable better optimizations +in the future: + +* Templates should be set on component classes as early as possible, ideally + immediately after the class is defined. +* A component's template cannot be set more than once. Attempting to change a + component's template after one has been set will throw an exception. +* Setting a component's template after rendering has begun (even if no previous + template was set) also throws an exception. +* Every instance of a given component has the same template. It is not + possible to dynamically change a component's template at runtime. + +Component subclasses inherit their template from the nearest superclass that +has had a template set via `setComponentTemplate`. If no superclass has an +associated template, Ember will fall back to the current template resolution +rules. + +Templates set via `setComponentTemplate`, including those set on a +superclass, take precedence over the `layout` property, container lookup, or +any other existing mechanism of template resolution. For example, a template +set on a superclass via `setComponentTemplate` would take precedence over a +subclass's `layout` property (if it had one). However, a template set via +`setComponentTemplate` on the subclass would take precedence over the +superclass. + +### Template Scope + +In this context, _scope_ refers to the set of names that can be used to refer +to values, helpers, and other components from within your template. For +example, when you type `{{@firstName}}`, `{{this.count}}`, or ``, how does Ember know what each of these refers to? The answer to that is +determined by the template's scope. + +Today, the names available inside a particular template are determined by a +few different rules: + +1. `this` always refers to the component instance (if the template has a + backing class). +2. Arguments (like `@firstName`) are placed into scope when you invoke the + component. +3. Block parameters (like `item` in `{{#each @items as |item|}}`) are in + scope, but only inside the block. +4. Anything else, like components and helper names, must be looked up + dynamically through Ember's container. This lookup process is somewhat + complex and not always as fast as we'd like. + +Because the rules for how components and helpers are looked up are implicit +and can be confusing for new learners, we'd like to explore alternate APIs +that allow users to explicitly import values and refer to them from +templates. + +This RFC proposes that templates embedded via the `createEmbeddableTemplate` +function resolve unknown identifiers from the ambient JavaScript scope, +rather than performing dynamic resolution through the container. + +To do this, `createEmbeddableTemplate` emits JavaScript code that contains +references to the same identifiers as those used in the template: + +```js +import { t } from '../helpers/t'; +import User from './user'; +import { __template__ } from '@ember/template-compiler'; + +const template$1 = __template__({ + source: '{{t "Hello!"}} ', + mode: 1, + scope: [User, t] +}); /* ^------- actual JavaScript identifier */ +``` + +When the embedded template is later compiled into the wire format, these references +are retained: + +```js +import { t } from '../helpers/t'; +import User from './user'; +import { templateFactory } from '@ember/template-factory'; + +const template$1 = templateFactory({ + "id": "ANJ73B7b", + "block": "{\"statements\":[\"...\"]}", + "scope": () => [User, t] +}); /* ^------- actual JavaScript identifier */ +``` + +If a template references an identifier that is not in scope, a +`ReferenceError` will be produced at runtime: + +```js +import { templateFactory } from '@ember/template-factory'; + +const template$1 = templateFactory({ + "id": "ANJ73B7b", + "block": "{\"statements\":[\"...\"]}", + "scope": () => [MissingComponent] +}); /* ^------- will produce a ReferenceError */ +``` + +As of this RFC, scope values resolved via ambient scope are limited to +component classes and template helpers (i.e. subclasses of `Helper` or +functions wrapped with `helper()` from `@ember/component/helper`). However, +support for additional kinds of scope values may be expanded in future RFCs. + +### Compilation Modes + +To support future evolution of template syntax and semantics, this RFC +defines a `mode` option that can specify different rules for template +compilation. + +This RFC defines a single mode: + + Mode | ID | Description +---------|--------------|------------ +**Strict** | 1 | Legacy template syntax becomes a compile-time error. Component and helper resolution happens via ambient JavaScript scope. + +The exact behavior of strict mode is defined in a companion RFC. + +Modes are specified as an integer ID to reduce the size of compiled output +and to allow additional modes to be added in the future. To specify the mode, +pass a `mode` option to `createEmbeddableTemplate`: + +```js +const template = createEmbeddableTemplate('
', { + mode: 1 +}); +``` + +If the mode is not specified, strict mode (mode `1`) is assumed. Future RFCs +may specify additional modes. + +### Embeddable Template Format + +This section describes the JavaScript syntax generated by +`createEmbeddableTemplate` and consumed by the Ember CLI build system. +Similar to how Ember CLI automatically discovers `.hbs` files and compiles +them into the wire format, templates embedded in `.js` files with this syntax +will be replaced with the equivalent wire format at build time. + +The following constraints are important to the design described here: + +1. Parsing for embedded templates should be fast, to avoid regressing application + build times. +2. The syntax should be static and only require parsing, not evaluating, JavaScript. +3. The syntax should be valid JavaScript that will not cause incompatibility + with other tools. +4. The syntax should produce real values and use real JavaScript references + rather than relying on "inert" constructs like strings or comments. This + is to make the embedded templates resilient to tools like minifiers or + packagers that aggressively remove code that seems unused and without + side-effects. + +The format described here is not intended to be authored by hand. Addons and +other tools that need to generate embeddable templates are strongly +encouraged to use the `createEmbeddableTemplate` function rather than +generating the format manually. + +We propose a new export, `__template__`, from the `@ember/template-compiler` +package. An embedded template is defined by a `CallExpression` with the +imported `__template__` identifier (or its alias) as the callee and an +`ObjectLiteral` expression as the first and only argument: + +```js +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; +import { __template__ } from '@ember/template-compiler'; + +const template$1 = __template__({ + source: '

', + mode: 1, + scope: [BlogPost, titleize] +}); +``` + +The `ObjectLiteral` expression must contain the following property definitions: + +| `IdentifierName` | Description | `AssignmentExpression` | +|---|---|---| +| `source` | The original template source. | `StringLiteral` | +| `mode` | The mode (see [Compilation Modes](#compilation-modes)) the template should be compiled in. | `DecimalIntegerLiteral` | +| `scope` | Identifiers used in the template, sorted by identifier name. | `ArrayLiteral` containing zero or more `IdentifierName` expressions | + +While all property definitions are required, the order is unimportant and +they may be provided in any order. + +The `scope` array must be sorted by identifier name, using the sort behavior +[described in ECMA-262][262-sort]. Predictable sorting allows the template +compiler to associate scope values with the correct template identifier, even +if JavaScript identifiers have been mangled by a minifier. + +[262-sort]: https://tc39.github.io/ecma262/#sec-array.prototype.sort + +To avoid naming collisions, the `__template__` identifier may be aliased on import: + +```js +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; +import { __template__ as TEMPLATE } from '@ember/template-compiler'; + +// This alias is valid and will be detected as an embedded template. +const template$1 = TEMPLATE({ + source: '

', + mode: 1, + scope: [BlogPost, titleize] +}) +``` + +Any other form of aliasing is not supported: + +```js +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; +import { __template__ } from '@ember/template-compiler'; + +// This alias happens after the import and will either not be detected or cause +// an exception to be thrown. +const TEMPLATE = __template__ +const template$1 = TEMPLATE({ + source: '

', + mode: 1, + scope: [BlogPost, titleize] +}) +``` + +The `__template__` `CallExpression` will be replaced with the compiled wire +format and wrapped in a template factory: + +```js +import titleize from './helpers/titleize'; +import BlogPost from './components/blog-post'; +import { createTemplateFactory } from '@ember/template-factory'; + +const template$1 = createTemplateFactory({ + "id": "ANJ73B7b", + "block": "{\"statements\":[\"...\"]}", + "scope": () => [BlogPost, titleize] +}); +``` + +Note that the wire format is not defined in this RFC, is not considered +public API, and is subject to change over time. The example above is +illustrative only. + +## How we teach this + +### Guide-level Documentation + +This is a low-level API, and most Ember developers should never need to learn +or care about it. Only addon authors interested in providing alternate file +formats need to understand these APIs in depth. + +As such, the examples and explanations given in this RFC should be enough for +experienced addon authors to get started. Of course, familiarity with +Broccoli, parsers like Babel, and creating Ember CLI addons is a +pre-requisite. + +### API-Level Documentation + +#### createEmbeddableTemplate + +```js +import { createEmbeddableTemplate } from '@ember/template-compiler'; +``` + +Encodes a string of Glimmer template source code as JavaScript code that can +be embedded in a host JavaScript file. The JavaScript representation will later +be compiled into the final compiled wire format by Ember CLI. + +Returns an object with a `prelude` property and a `value` property. The +`prelude` string should be inserted into the top-level scope of the host file +and includes setup code such as import statements. The `value` string +represents a JavaScript expression and may be inserted anywhere a JavaScript +expression is syntactically valid, such as the right side of an assignment. + +Components and helpers referenced in the passed template will be +looked up from the JavaScript scope where the `value` string is inserted. + +An optional `mode` option may also be provided. Currently, only one mode is +supported, which is strict mode with ID of `1`. If no mode is specified, +the default mode of `1` is assumed. Future RFCs may specify additional modes. + +```ts +function createEmbeddableTemplate(templateSource: string, options?: CreateEmbeddableTemplateOptions): CreateEmbeddableTemplateResult; + +interface CreateEmbeddableTemplateOptions { + mode?: TemplateCompilerMode; +} + +interface CreateEmbeddableTemplateResult { + prelude: string; + value: string; +} + +enum TemplateCompilerMode { + Strict = 1; +} +``` + +#### setComponentTemplate + +```js +import { setComponentTemplate } from '@ember/template-factory'; +``` + +Associates a template factory with a component class. When a component is +invoked, a new instance of the class is created and the template associated +with it via `setComponentTemplate` is rendered. + +```ts +function setComponentTemplate(template: TemplateFactory, ComponentClass: T): T; +``` + +## Drawbacks + +### Requires Addons + +The most obvious drawback is that the APIs outlined here, once implemented, +stabilized, documented, and landed in a stable release, do not provide any +value to Ember users if no one build addons that take advantage of them. + +On the other end of the spectrum, there's the risk that people build _too many_ addons +using these primitives, causing fragmentation and confusion in the Ember ecosystem. + +There's also a risk that people find single-file component addons extremely +productive and they become popular among "pro users," but new Ember learners +aren't aware of them. Once these ideas are validated and shown to be an +improvement, it's important they are incorporated into the happy path so we +can climb the mountain together. + +### Relies on Runtime Behavior + +While this proposal tries to be strict about reducing the dynamism around how +component templates are specified, it still relies on having objects like +template factories available to imperatively annotate at runtime. + +If and when we add support for Glimmer's AOT bytecode compilation mode, these +objects will no longer exist, and the strategy of having addons simply +compile the template portion of single-file components into an inline wire +format will no longer work. + +While I don't consider this a blocker, it does mean that when the time comes, +existing SFC addons would likely need to be updated to support AOT +compilation, unless we can come up with a clever compatibility hack. + +## Alternatives + +### Providing Scope Explicitly + +As currently proposed in this RFC, template identifiers are looked up from +ambient scope, with an optional fallback to dynamic resolution behavior. + +An alternative considered was to specify scope values explicitly when +creating the template factory. However, this adds boilerplate that is +unneeded in the most common scenarios. + +In favor of the extra boilerplate, addon authors can inject new bindings into +JavaScript scope to customize template resolution. + +### Additional Resolver-Based APIs + +One alternative to the API propsed here (where we explicitly attach a +template to a component class) would be to provide better hooks into the +resolver system so that addons can better simulate single-file components via +the existing dynamic resolution system. + +I rejected this approach for a number of reasons. First, the current resolver +already has quite a bit of branching and dynamic behavior, often for +backwards compatibility. Adding additional functionality and logic to this system +did not seem like a great option. + +Second, I believe the API proposed in this RFC is much simpler for addon +authors to understand and implement if they want to experiment with +single-file component file formats. More dynamic systems can also add more +layers of indirection that are difficult to understand and debug. + +Third, and most importantly, the static nature of the API as proposed here +makes it much easier to understand at build time which components and +templates go together, and what the dependencies between components are. The +ability to quickly and accurately determine this dependency graph is +imperative to the success of initiatives like Embroider and is required to +unlock good tree-shaking and code-splitting. + +## Unresolved questions + +## Changelog + +This section captures substantive changes made during the RFC process and the +rationale behind the changes. + +### 2019/02/26 + +#### Replace Wire Format with Embeddable Templates + +To address the previous open question about wire format stability and how +addons should distribute templates that exist within a JavaScript scope, the +previous `precompile` API has been replaced with a new API, +`createEmbeddableTemplate`, that creates an intermediate static syntax for +template embedding that includes the original template source. + +#### Single Scoping Mechanism, Compilation Mode + +Previous versions of this RFC contained additional API for partially opting +out of JavaScript lexical scope in a given template. This design added +complexity to the API and the process of building an SFC addon, but was done +on compatibility grounds. We now believe we have a better strategy for +compatibility that does not rely on this complexity, so it has been removed +in favor of always using ambient scope. + +This also means that the need for a sloppy mode is eliminated, so this RFC +defines the default (and only) mode as strict mode. The exact semantics of +strict mode will be defined in a companion RFC. + +### 2019/02/25 + +#### Simpler Scope API + +The details of how scope works have changed to better align with the planned +"strict mode" RFC for templates, and to reduce the boilerplate required in +most common scenarios. + +Previously, setting a template's scope was a two-step process: + +1. Specifying the identifiers during the `precompile` phase. +2. Specifying the associated values of the specifiers during the + `createTemplateFactory` phase. + +The API looked like this: + +```js +import { precompile } from '@ember/template-compiler'; +import { createTemplateFactory } from '@ember/template-factory'; +import User from './OtherComponent'; +import t from '../helpers/i18n/t'; + +// Provide array of string identifiers +const wireFormat = precompile(`{{t "Hello!"}} `, { + scope: ['User', 't'] +}); + +// Provide array of values +const template = createTemplateFactory(wireFormat, { + scope: () => [User, t] +}); +``` + +This API has been updated so that, by default, the expression returned from +`precompile` is assumed to be embedded in JavaScript code where the +identifiers used in the template are in lexical scope. This eliminates the +need to specify a `scope` array to either `precompile` or +`createTemplateFactory`. + +The example above would instead be written like this: + +```js +import { precompile } from '@ember/template-compiler'; +import { createTemplateFactory } from '@ember/template-factory'; +import User from './OtherComponent'; +import t from '../helpers/i18n/t'; + +// Assume any ambiguous identifiers come from our lexical scope. +const wireFormat = precompile(`{{t "Hello!"}} `); +const template = createTemplateFactory(wireFormat); +``` + +That means that the wire format can now contain JavaScript code, like in the +following example: + +```js +import { precompileTemplate } from '@ember/template-compilation'; +import User from './OtherComponent'; +import t from '../helpers/i18n/t'; + +// Provide array of string identifiers +const wireFormat = precompile(`{{t "Hello!"}} `); +/* => `{ + "id": "ANJ73B7b", + "block": "{\"statements\":[\"...\"]}", + "scope": () => [t, User] +}` */ +``` + +#### Mode Option + +A new `mode` option has been added to `precompile` to allow for future +evolution of compiler rules. \ No newline at end of file