Start Date | Relevant Team(s) | RFC PR |
---|---|---|
2019-06-21 |
Ember CLI, Ember.js, Learning |
This RFC defines a new package format that is designed to make Ember packages (meaning both apps and addons) statically analyzable and more compatible with the rest of the NPM & Javascript ecosystem. This RFC is the first step in stabilizing Embroider as our next-generation build system.
One of the good things about Ember is that apps and addons have a powerful set of build-time capabilities that allow lots of shared code with zero-to-no manual integration steps for the typical user. We have been doing “zero config” since before it was a cool buzzword (it was just called “convention over configuration”). And we’ve been broadly successful at maintaining very wide backward- and forward-compatibility for a large body of highly-rated community-maintained addons.
But one of the challenging things about Ember is that our ecosystem’s build-time capabilities are more implementation-defined than spec-defined, and the implementation has accumulated capabilities organically while only rarely phasing out older patterns. I believe the lack of a clear, foundational, build-time public API specification is the fundamental underlying issue that efforts like the various packaging / packager RFCs have tried to work around.
The benefits to users for this RFC (and Embroider in general) are:
- faster builds and faster NPM installs
- “zero-config import from NPM — both static and dynamic” as a first-class feature that works for both third-party libraries and Ember addons
- support for arbitrary code splitting
- tree-shaking of unused modules, components, helpers, etc from the app and all addons
- a layered build system with clearly documented APIs between the layers, so it's easier to experiment and contribute
- a build system that can take advantage of current and future investments by the wider Javascript ecosystem into code bundling & optimization.
Ember was one of the earliest adopters of ECMAScript modules, and Ember core team members were directly involved in helping move that features through TC39. Ember’s early experiences with modules influenced the spec itself. Yet we have lagged in truly embracing modules.
For example, how do Ember apps express that they depend on a third-party library? The app.import API. This should be ECMA standard import
.
Another way to state the problem is that apps and addons all push whatever code they want into the final built app. Whereas ES modules can pull each other into the build as needed.
The ECMA module spec by itself doesn’t try to define a module resolution algorithm. But the overwhelmingly most popular convention is the node_modules resolution algorithm.
Ember addons do respect node_module resolution for build-time code, but they do not respect it for runtime code. This is an unhelpful distinction.
Ember’s strong conventions mean that many kinds of dependencies can be inferred (including statically inferred) without requiring the developer to laboriously manage them. This is a good thing and I believe the current fad in the wider Javascript ecosystem for making developers hand-write verbose static imports for everything confuses the benefits of having static analysis (which is good) with the benefits of hand-managing those static imports (which is unnecessary cognitive load when you have clear conventions and a compiler).
This design is about compiling today’s idiomatic Ember code into more “vanilla” patterns that leverage ES modules, node_modules resolution, and spec-compliant static and dynamic import
to express the structure of an Ember application in a much more “vanilla Javascript” way.
This compile step lets us separate the authoring format (which isn’t changing in any significant way in this RFC) from the packaging format (which can be more verbose and static than we would want in an authoring format).
package: every addon and app is a package. Every "NPM package" is a package, but not every package is an "NPM package" because we also include in-repo packages. The most important fact about a package is that it’s often the boundary around code that comes from a particular author, team, or organization, so coordination across packages is a more sensitive design problem than coordination within apps.
app: a package used at the root of a project.
addon: a package not used at the root of a project. Will be an allowed dependency of either an app or an addon.
allowed dependency: For addons, the allowed dependencies are the packages listed in dependencies
and peerDependencies
in package.json
plus any in-repo addons. For apps, the allowed dependencies are the dependencies
, peerDependencies
, and devDependencies
in package.json
plus any in-repo addons.
Ember package metadata: the ember
section inside package.json
. This already exists in v1 (it's used to set edition
), we’re going to extend it.
v2 package: a package with package.json
like:
"keywords": [ "ember-addon" ],
"ember": {
"version": 2
}
v1 package: a package with package.json
like:
"keywords": [ "ember-addon" ]
and no version
key (or version key less than 2) in Ember package metadata.
non-Ember package: a package without keywords: ["ember-addon"]
This RFC is intended as the base level spec for v2 Ember packages. It does not attempt to cover everything a v1 package can do today. For example, no provision is made in this RFC for:
- providing dev middleware
- providing commands and blueprints
- preprocessing your parent package's code
- modifying your parent package's babel config
- injecting content into index.html (contentFor)
It is understood that all of these are legitimate things for Ember addons to do. Defining these capabilities within v2 packages will be done in followup RFCs. It is simply too much scope to cover in one RFC.
Because we're hyper-focused on backward- and forward-compatibility, there is no harm in progressively converting some addons to v2 (which provides immediate benefits in terms of build performance and reduced fragility under Embroider) while others need to stay as v1 until we offer the features they need.
This RFC is carefully decoupled from other new Ember features (like strict mode ), but it is understood that the spec created by this RFC will become a living standard that needs to both influence and be influenced by the rest of Ember's design.
The format we are about to describe is a publication format. Not necessarily an authoring format. By separating the two, we make it easier to evolve the authoring format without breaking ecosystem-wide compatibility. The publication format is deliberately more explicit and less dynamic that what we may want for an authoring format.
First, here’s the list of things a v2 package can provide. More detail on each of these will follow:
- Own Javascript: javascript and templates under the package’s own namespace (the v1 equivalent is
/addon/**/*.{js,hbs}/
) - App Javascript: javascript and templates that must be merged with the consuming app’s namespace (the v1 equivalent is
/app/**/*.{js,hbs}
). Other RFCs are working to move Ember away from needing this feature, but we are not gated on any of those and fully support App Javascript. - Fastboot Javascript: just like App Javascript, but only applies when running in Fastboot.
- CSS: available for
@import
by other CSS files (both in the same package and across packages) and by ECMAimport
directives in Javascript modules (both in the same package and across packages). - Implicit Dependencies: scripts, modules, and stylesheets that should be implicitly included in the app or the app's tests whenever this addon is active. This is a backward-compatibility feature.
- Renaming Rules: allow a package to declare that some of its modules should be available at different import paths than their real, resolvable path. This is a backward-compatibility feature that new addons should not use.
- Externals: allows a package to declare that it imports some things that are not allowed dependencies. Instead of being resolved at build time, externals are deferred until runtime and get handled by the traditional
loader.js
require()
. - Assets: any files that must be available in the final built application directory such that they have public URLs (typical examples are images and fonts).
- Build Hooks: code that runs within Node at application build time. The v1 equivalent is an addon's
index.js
file.
The public main
(as defined in package.json
) of a v2 package points to its Own Javascript. The code is formatted as ES modules that follow the Ember Language Standard (which is defined later in this RFC). Addon authors can still author in whatever dialect they want, but they must transpile to Ember Language Standard before publishing to NPM.
Templates are in hbs format. No custom AST transforms are supported. Addon authors can still use custom AST transforms, but those transforms must have already been applied and re-emitted into hbs source text before publication to NPM. This means that only AST transforms whose output are serializable into hbs source text are permitted.
Unlike v1 addons, there is no /app
or /addon
directory that is magically removed from the runtime paths to the modules. All resolution follows the prevailing Node rules.
The benefit of this design is that it makes our packages understandable by a broader set of tooling. Editors and build tools can follow import
statements across packages and end up in the right place.
In v1 packages, main
usually points to a build-time configuration file. That file is moving and will be described in the Build Hooks section below.
Modules in Own Javascript are allowed to use ECMA static import
and dynamic import()
to resolve any allowed dependency, causing it to be included in the build whenever the importing module is included. This replaces app.import
. Resolution follows prevailing Node rules. (This usually means the node_modules algorithm, but it could also mean Yarn PnP. The difference shouldn't matter if you are correctly declaring all your allowed dependencies.)
Notice that a package’s allowed dependencies do not include the package itself. This is consistent with how Node resolution works. To import files from within your own package you must use relative paths. This is different from how run-time AMD module resolution has historically worked in Ember Apps. (@embroider/compat
implements automatic adjustment for this case when compiling from v1 to v2).
Modules in Own Javascript get no automatic component template co-location. They should use Ember's setComponentTemplate
explicitly. They can access their compiled templates either by importing them or by inlining them via the hbs
macro provided by ember-cli-htmlbars.
We're assuming that template co-location will be handled by the build step before an addon is published to NPM in V2 format.
Similarly, template-only components must be explicitly present as Javascript modules that invoke Ember's templateOnlyComponent()
and setComponentTemplate
.
Any module you import, whether from an Ember package or a non-Ember package, gets transpiled by default such that it will support the app's set of browser targets. This ensures that the app's config/targets.js
will always be respected and you won't accidentally break your older supported browsers by importing a dependency that uses newer ECMA features.
Today in practice this means everything you import will go through babel-preset-env, and babel-preset-env will use the same configuration that it uses in the app.
There is an explicit per-package opt-out for cases where you're sure that transpilation is not needed and not desirable. (See Build Hooks for details on the skipBabel
option.)
As already stated, V2 Ember packages must contain only ES modules. However, non-Ember packages in your allowed dependencies are allowed to contain ES modules or CommonJS modules. This provides the best compatibility with general-purpose NPM utilities.
The V2 format deliberately eliminates many sources of app-build-time dynamism from addons. Instead, we provide an equivalently-powerful macro system and consider it an always-supported language extension (the macros are always available to every V2 package, ambiently, and we promise to give them their faithful build-time semantics).
See Macro System for the full details.
To provide App Javascript, a package includes the app-js
key in Ember package metadata. For example, to duplicate the behavior of v1 packages, you could say:
"ember": {
"version": 2,
"app-js": "./app"
}
Like the Own Javascript, the code must be authored as ES modules in Ember Language Standard. ECMA static and dynamic imports from any allowed dependency are supported. Even though the app javascript will be addressable within the app's module namespace, your own imports still resolve relative to your addon.
By making app-js
an explicit key in Ember package metadata, our publication format is more durable (you can rearrange the conventional directory structure in the future without breaking the format) and more performant (less filesystem traversal is required to decide whether the package is using the App Javascript feature.
To provide Fastboot Javascript, a package includes the fastboot-js
key in Ember package metadata. This works almost identically to App Javascript, except it means the given directory will only be merged into the app's namespace when running in Fastboot (server-side rendering) mode. This key is a backward compatibility feature that allows addons with a fastboot
directory to express their needs.
New v2 packages don't need to use fastboot-js
because they can use the macro system to branch between browser and server implementations.
To provide CSS, a package can include any number of CSS files. These files can @import
each other via relative paths, which will result in build-time inclusion (as already works in v1 packages).
If any of the Own Javascript or App Javascript modules depend on the presence of a CSS file in the same package, it should say so explicitly via an ECMA relative import, like:
import '../css/some-component.css';
This is interpreted as a build-time directive that ensures that before the Javascript module is evaluated, the CSS file's contents will be present in the DOM. ECMA import of CSS files must always include the explicit .css
extension.
Q: Does this interfere with the ability to do CSS-in-JS style for people who like that?
A: No, because that would be a preprocessing step before publication. It’s a choice of authoring format, just like TypeScript or SCSS. CSS-in-JS people would compile all their things to ES modules before we deal with it.
It is also possible for other packages (including the consuming application) to depend on a CSS file in any of its allowed dependencies, from either Javascript or CSS. From Javascript it looks like:
// This will resolve the `your-addon` package and find
// './some-component.css' relative to the package root.
// The .css file extension is mandatory
import 'your-addon/some-component.css';
And from CSS it looks like:
@import 'your-addon/some-component';
What about SCSS et al? You’re still free to use them as your authoring format, and they should be transpiled to CSS in your publication format. If you want to offer the original SCSS to consuming packages, you’re free to include it in the publication format too. Since we’re making all packages resolvable via normal node rules, it’s now dramatically easier to implement a preprocessor that supports inter-package dependencies. (The same logic applies to TypeScript.)
Within Ember package metadata we support several flavors of implicit dependencies:
- implicit-modules
- implicit-scripts
- implicit-styles
- implicit-test-modules
- implicit-test-scripts
- implicit-test-styles
Each one is a list of package-relative paths to files within the package.
Whenever your package is active, all of its implicit dependencies will be included in the build. The -test-
variants will be included only in test suites, and the non--test-
variants are always included.
implicit-modules
and implicit-test-modules
mean that the app should be built as if someone has explicitly typed ECMA import statements for each of the listed modules.
implicit-scripts
and implicit-test-scripts
are for Javascript in a script context. Script context is different from module context (as defined by the ECMA spec). This is how an addon can push things into the equivalent of the traditional vendor.js
, which is in script context.
implicit-styles
and implicit-test-styles
are for stylesheets. This is how an addon can push things into the equivalent of the traditional vendor.css.
While Implicit Dependencies are a fully-supported part of the v2 spec, v2 packages are encouraged to use direct ECMA import
or CSS @import
instead, whenever possible. A direct import
provides finer-grained dependency information: we know exactly which module inside your package actually depends on the thing, rather than needing to assume that your entire package depends on it.
For example, if one of your components depends on a third-party library, you should import
that library directly from your component. Then the library will only be included if somebody uses that particular component. Whereas if you use implicit-scripts
, the library will always be included, even if nobody uses the component that needs the library.
V1 Addons have multiple ways (at least five that I've found so far!) of emitting modules that escape the addon's own package namespace. Examples:
ember-lodash
remaps all of its modules to the package namelodash
@ember/test-helpers
provides some modules under its true name, but also some modules underember-test-helpers
.
In order for Embroider compile these packages to valid V2 packages, we give V2 packages the ability to express renaming rules using the following properties in Ember package metadata:
-
renamed-packages
: a map from the package name a user would type to the real package name that provides it. Example:{ "renamed-packages": { "lodash": "ember-lodash" } }
-
renamed-modules
: a map from modules that a user may try to import to the real paths where those modules live:"renamed-modules": { "ember-test-helpers/index.js": "@ember/test-helpers/ember-test-helpers/index.js" }
When Embroider compiles a V1 package like
@ember/test-helpers
it ensures that the modules that would have "escaped" the package end up inside the package, so that this kind of renaming works.The renaming rules allow these addons to adopt V2 format without breaking their public API. New addons should not use renaming rules because it's confusing when the imports people type don't align with their real dependencies.
The externals
property in Ember package metadata allows a V2 addon to declare specific imported modules that should not be resolved at build time. Instead they will be resolved at runtime using the traditional loader.js
require()
.
When Embroider compiles V1 packages to V2 it does automatic externals detection.
When publishing a native V2 package, any externals need to be listed explicitly in Ember package metadata.
An example of when you may need externals
is when you need to consume a script (not a module) that contains arbitrary define()
statements. The modules defined by those statements aren't resolvable, in general, at build time. So attempts to import them will generate build errors. By listing them in externals, you can defer them until runtime where they will work.
To provide Assets, a package includes the public-assets
key in Ember package metadata. It's a mapping from local paths to app-relative URLs that should be available in the final app. For example:
"name": "my-addon",
"ember": {
"version": 2,
"public-assets": {
"./public/image.png": "/my-addon/image.png"
}
}
with:
my-addon
└── public
└── image.png
will result in final build output:
dist
└── my-addon
└── image.png
Notice that we’re not choosing to include assets via explicit ECMA import
. The reason is that fine-grained inclusion of asset files is not critical to runtime performance. Any assets that your app doesn’t actually need, it should never fetch. Assets are always things with their own URLs.
If two V2 packages try to emit assets with the same public URL, that's a build error.
Q: Should we just automatically namespace them instead? A: That was considered, but it makes backward compatibility harder, and public URLs are not always free to choose/change.
In today’s v1 addon packages, the index.js
file is the main entrypoint that allows an addon to integrate itself with the overall ember-cli build pipeline. The same idea carries forward to v2, with some changes.
It is no longer the main
entrypoint of the package (see Own Javascript). Instead, it’s located via the build
key in Ember package metadata, which should point at a Javascript file. build
is optional — if you don’t have anything to say, you don’t need the file.
It is now an ECMA module, not a CJS file. The default export is a class that implements your build hooks (there is no required base class).
Here is a list of build hooks, each of which will have its own section below.
- configure
- configureDependencies
- skipBabel
I will describe the hooks using TypeScript signatures for precision. This does not imply anything about us actually using TypeScript to implement them. Each package has two type variables:
PackageOptions
is the interface for what options you accept from packages that depend on you. It's your package's build-time public API.OwnConfig
is the interface for the configuration that you want to send to your own code, which your code can access via thegetOwnConfig
macro. This is how you influence your runtime code from the build hooks.
interface ConfigurationRequest<PackageOptions> = {
options: PackageOptions,
fromPackageName: string,
fromPackageRoot: string,
};
configure<PackageOptions, OwnConfig>(
requests: ConfigurationRequest<PackageOptions>[]
): OwnConfig
The configure hook receives an array of configuration requests. Each request contain the PackageOptions
that a package that depends on this addon has sent to this addon. It also includes the fromPackageName
and fromPackageRoot
(the full path on disk to the requesting package) so that any configuration errors can blame the proper source.
configure
deals with an array because multiple packages may depend on a single copy of our package. But our package can only be configured in one way (for example, we are either going to include some extra code or strip it out via the macro system, but we can't do both).
Addons are encouraged to merge configuration requests intelligently to try to satisfy all requesters. If it's impossible to do so, you can throw an error that explains the problem.
The OwnConfig
return value must be JSON-serializable. It becomes available to your Own Javascript via the getOwnConfig
macro, so that it can influence what code is conditionally compiled out of the build.
configureDependencies(): {
[dependencyName: string]: PackageOptionsForDependency | "disabled"
}
The configureDependencies
hook is how you send configuration down to your own dependencies. For each package in your allowed dependencies you may return either the PackageOptions
expected by that package, or the string "disabled"
.
Any dependencies that you don't mention are considered active, but don't receive any configuration from you.
Any dependency for which you provide PackageOptions
is active, and will receive those PackageOptions
in its own configure
hook.
If you set a package to "disabled"
, it will not become active because of your addon. It may still become active if another package depends on it and leave it active.
When and only when a package is active:
- all standard Ember module types (
your-package/components/*.js
,your-package/services/*.js
, etc) from its Own Javascript that cannot be statically ruled out as unnecessary are included in the build as if some application code hasimport
ed them. (What counts as “cannot be statically ruled out” is free to change as apps adopt increasingly static practices. This doesn’t break any already published packages, it just makes builds that consume them more efficient.) - all of the package's Implicit Dependencies are included in the build.
- all App Javascript is included in the build.
- all Assets are included in the build.
- the package's Active Dependencies become active recursively.
Whether or not a package is active:
- directly-imported Own Javascript and CSS are available to any other package as described in those sections. The rationale for allowing
import
of non-active packages is that (1) we follow node module resolution and node module resolution doesn’t care about our notion of “active”, and (2)import
is an explicit request to use the module in question. It’s not surprising that it would work, it would be more surprising if it didn’t.
The configureDependencies
hook is the only way to disable child packages. The package hooks are implemented as a class with no base class. There is no super
to manipulate to interfere with your children’s hooks.
skipBabel(): { package: string, semverRange?: string }[]
By default, all imported dependencies (and their recursive imported dependencies) go through the app's babel config. This ensures browser compatibility safety. However, we provide skipBabel
as an opt-out to work around transpilation problems in cases where the developer has verified that transpilation of a given package isn't needed.
skipBabel
returns a list of package names and optionally semver ranges. If no range is included, it defaults to *
. This is a place where you're allowed to mentioned packages that are not in your allowed dependencies because it may be necessary to talk about deeper dependencies within them. The skipBabel
settings for all active addons are combined and if any addon skips babel for a given package & version, that causes the package to not be transpiled.
The semver range is useful to disambiguate if there are multiple versions of the same package involved in the app, and in cases where a developer has manually verified that transpilation isn't needed, it's good practice to use the semver range so that skipBabel
doesn't accidentally apply to a future version of the package that may indeed need transpilation.
v1 packages can provide treeForTestSupport
, treeForAddonTestSupport
, and app.import
with type="test"
. All of these features are dropped.
To provide test-support code, make a separate module within your package and tell people to import
it from their tests. As long as it is only imported from tests, it will not be present in non-test bundles. (Things get simpler when you respect the module dependency graph.)
v1 packages can run arbitrary Node code that completely alters their runtime code. This makes them impossible to analyze. v2 packages are not allowed to do this. There are no "treeFor*" hooks. Instead, they can influence their runtime code only through the macro system.
It helps to think about the macro system as an extension to Javascript itself that we allow in v2 packages. Because the macros are allowed to appear in any published V2 package, and because the macros are not a dependency that each package can control (you don't get to bring your own separate macro system version with you), it's important that we design a small core that we can support for the long-term. We probably can't make breaking changes to the macro system, we can only introduce new macros.
(I'm proposing the macros live under @ember/macros
. The current implementation of them lives in @embroider/macros
.)
The Javascript macros are:
- importSync
- getOwnConfig
- getConfig
- macroCondition
- each
- moduleExists
- dependencySatisfies
- failBuild
The Handlebars macros are:
- macroGetOwnConfig
- macroGetConfig
- macroCondition
- macroDependencySatisfies
- macroMaybeAttrs
- macroFailBuild
The difference in naming is because the JS macros get explicitly imported from @ember/macros
, whereas the Handlebars macros do not, so they need an appropriate namespace prefix. (If we land template imports, I'm fine with adjusting this RFC to make the names align.)
import { importSync } from "@ember/macros";
importSync("some-dependency").default;
importSync
exists to do a thing that standard Javascript does not do: synchronous dynamic import. Ember historically needs synchronous dynamic import (it's what our runtime AMD require
does). Until some future date at which Ember has migrated away from synchronous module resolution we need importSync
.
importSync
is defined as behaving exactly like the standards-compliant import()
except instead of returning Promise<Module>
it returns Module
.
In this RFC we don't state explicitly what importSync
compiles to. It compiles to whatever the Javascript bundler we're using supports in order to achieve synchronous dynamic import. For example, if we're internally using Webpack we can compile importSync("something")
to require("something")
, because Webpack supports CommonJS require
anywhere.
// this example:
import { getOwnConfig } from "@ember/macros";
const shouldEnableCoolFeature = getOwnConfig().coolFeature;
// might compile to:
const shouldEnableCoolFeature = true;
// assuming your ownConfig is `{ coolFeature: true }`.
getOwnConfig()
behaves like a function that returns your OwnConfig
, as determined by the return value of your configure
Build Hook. You're allowed to chain property access and optional property access (including array indices) off of getOwnConfig()
. Since the OwnConfig
is required to be JSON-serializable, any subset of it can be accessed this way, and we inline that value directly into the code.
You can choose to inline the whole OwnConfig if you want to:
// this example:
import { getOwnConfig } from "@ember/macros";
const myConfig = getOwnConfig();
// might compile to:
const myConfig = { coolFeature: true };
// assuming your ownConfig is `{ coolFeature: true }`.
Since getOwnConfig
accesses the output of your configure
build-hook, you have a place to run arbitrary build-time code and inject the results into runtime Javascript.
getConfig
can access the OwnConfig
of your dependencies.
import { getConfig } from "@ember/macros";
const testSelectorsConfig = getConfig("ember-test-selector");
It supports property chaining the same as getOwnConfig
.
This is a low-level power tool. It's mostly useful as a compile target for custom Babel plugins. For example, ember-test-selectors
has a custom Babel plugin that sometimes strips test properties out of your components. But if a V2 package is using ember-test-selectors, it needs to run the custom transform before publishing. At that point, it's too soon to decide whether to strip them.
Instead of actually doing the stripping, the ember-test-selector plugin would compile the user's code into code that uses macroCondition
and getConfig('ember-test-selectors')
. In this way, we get the powerful custom behavior, but only the standard core macros are needed at the time when the app itself is building.
macroCondition
acts like a function that takes a boolean value and returns that same boolean value. But whenever macroCondition
appears directly inside the predicate of an if
statement or as the predicate of a ternary expression, it tells the macro system to do branch elimination based on the predicate. Here is an example that combines three of the macros we've seen so far:
// This example:
import { macroCondition, getOwnConfig, importSync } from "@ember/macros";
let implementation;
if (macroCondition(getOwnConfig().useNewVersion)) {
implementation = importSync("./new-component");
} else {
implementation = importSync("./old-component");
}
export default implementation;
// ==============
// Could compile down to this if OwnConfig contains { useNewVersion: true }
let implementation;
implementation = importSync("./new-component");
export default implementation;
// ===============
// Or compile down to this if OwnConfig contains { useNewVersion: false }
let implementation;
implementation = importSync("./old-component");
export default implementation;
It is a build error if macroCondition
cannot statically determine the truth status of its argument. It's a build error if macroCondition
appears anywhere other than as the predicate of an if statement or ternary expression.
macroCondition
supports boolean logic, like macroCondition(getOwnConfig().a && getOwnConfig().b)
.
Here is an example of macroCondition
in a ternary expression:
// this example:
const flavor = macroCondition(getOwnConfig().prefersChocolate)
? "chocolate"
: "vanilla";
// could compile down to:
const flavor = "chocolate";
macroCondition
is the foundation that lets us choose which code to include in the build. You can choose to inline two different implementations within the branches of an if
statement, or you can keep them in entirely separate modules and import only the correct one via importSync
.
It would also be possible (in the future, when top-level await stabilizes) to use top-level await to replace usage of our importSync
macro with standards-compliant import()
:
if (macroConditional(getOwnConfig().x)) {
await import("x");
} else {
await import("y");
}
Q: Why not allow if (getOwnConfig().thing)
instead of if (macroCondition(getOwnConfig().thing))
?
A: Because we don't want to leave any confusion over whether branch elimination will be done. Boolean expressions that include a macro like getOwnConfig
alongside other runtime-only values are perfectly legal. But those expressions would not allow branch elimination. The ambiguity means you might accidentally defeat branch elimination without noticing. macroCondition
is intended to signal -- both to the reader and to the compiler -- that this place absolutely must do branch elimination. It's an error if we can't eliminate one branch or the other.
each
allows you to unroll a loop based on an array value provided by another macro. It behaves like the identity function (returning its argument unchanged), but it provides the special guarantee that if you use it as the argument of a for ... of
loop, the loop will be unrolled:
// This example:
import { getOwnConfig, each, importSync } from "@ember/macros";
let plugins = [];
for (let plugin of each(getOwnConfig().registeredPlugins)) {
plugins.push(importSync(plugin).default);
}
// could compile to this, given OwnConfig
// containing { registeredPlugins: ['@bigco/bar-chart', '@bigco/line-chart']}
let plugins = [];
plugins.push(importSync("@bigco/bar-chart").default);
plugins.push(importSync("@bigco/line-chart").default);
It is a static error if the argument to each
does not evaluate to a statically known array.
Allow you to test if an import
(or import()
or importSync()
, since they all accept an argument with identical semantics) would succeed. Always compiles to a boolean literal.
import { moduleExists, macroCondition, importSync } from "@ember/macros";
if (macroCondition(moduleExists("ember-data"))) {
const DS = importSync("ember-data").default;
DS.Adapter.extend({
//
});
}
Remember that you're always only allowed to import
from your own allowed dependencies. So if an addon wants to optionally use another package only if that package is present, that package must be listed as an Optional Peer Dependency.
moduleExists
differs in one respect from all the other macros: there is no reliable build-time polyfill for it in non-Embroider builds. So when you use it in a non-Embroider build, you get a runtime implementation instead (equivalent to today's require.has()
. If you need build-time branch elimination, try to use dependencySatisfies
instead, which works correctly in both Embroider and non-Embroider builds.
Allows you to test if the given allowed dependency satisfies the given semver range. Always compiles to a boolean literal.
import { dependencySatisfies, macroCondition } from "@ember/macros";
if (macroCondition(dependencySatisfies("ember-data", "^3.0.0"))) {
// include code here for ember-data 3.0 compat
}
The package version will be semver.coerce
d first, such that non-standard versions like "3.9.0-beta.0" will appropriately satisfy constraints like "> 3.8".
Allow you to cause a build failure with a custom error message. If failBuild
isn't eliminated by macroCondition
's branch elimination, the build will fail.
import { dependencySatisfies, macroCondition, failBuild } from "@ember/macros";
if (macroCondition(dependencySatisfies("ember-data", "^3.0.0"))) {
// include code here for ember-data 3.0 compat
} else {
failBuild(`We don't support ember-data versions other than ^3.0.0`);
}
macroGetOwnConfig
is very similar to the getOwnConfig
JS macro, but it works as a Handlebars helper. Given this OwnConfig
:
{
"items": [{ "score": 42 }]
}
Then:
If you don't pass any arguments, you can get the whole thing (although this makes your template bigger, so use arguments when you can):
The equivalent of the getConfig
JS macro as a Handlebars helper. Given a dependency ember-score
exposes this config:
{
"items": [{ "score": 42 }]
}
Then:
Used as a helper within a block {{#if}}
or inline {{if}}
. Just like the JS macroCondition
, it ensures that branch elimination will happen.
Acts like a helper that returns a boolean. Like the dependencySatisfies
JS macro, it can only resolve things that are allowed dependencies, so the same need for peer dependencies and/or Optional Peer Dependencies applies.
There is one place where {{#if}}
doesn't work: within "element space". If you want to sometimes set an attribute, but sometimes not, this doesn't work:
macroMaybeAttrs
exists to conditionally compile away attributes and arguments out of element space:
It can be placed on both HTML elements and angle bracket component invocations.
Like the JS failBuild
macro.
All the macros are intended to be valid syntax. They shouldn't break parsing or linting.
While we guarantee that branch elimination will run in production builds, we don't guarantee that in development. The macros are designed so that in development they may have runtime implementations. This is powerful because it lets us produce a single build that works in multiple contexts. For example:
-
it solves the longstanding problem that when you run your tests by visiting
localhost:4200/tests
the tests see thedevelopment
environment, not thetest
environment. To get the test environment you can't useember serve
, you must useember test
. This has remained unfixed because it's expensive to do the whole build twice for the two environments.We can solve this problem by producing a single build containing both environments, guarded by the macro system. The macros can evaluate at runtime, allowing each environment to get the right thing. In production builds, test-only or dev-only branches will still be eliminated.
-
it makes Fastboot builds simpler because we can guard the fastboot-only and browser-only code with the macro system. In development, we can run a single build that leaves both branches in and evaluates the macros at runtime.
The macros package (@ember/macros
as proposed, @embroider/macros
as implemented) will work in both regular ember-cli and in Embroider. And it will work in both V1 and V2 packages.
V2 packages can only resolve their allowed dependencies. This is fundamental rule that we can't break if we want the broadest compatibility with NPM and future compatibility with other strict systems such as Yarn PnP. Node often allows you to resolve things that are not allowed dependencies due to hoisting optimizations. But this is not safe or guaranteed, so we forbid relying on it.
This means that many things addons will try to access from their surrounding environment will need to be listed as peerDependencies
. For example, addons that want to import ember-data
should list ember-data
as a peerDependency
, so the app can control the ember-data
version and the addon is guaranteed to resolve the same copy.
This also applies recursively -- if your addon wants to use an addon that needs ember-data
, your addon should also list ember-data
as a peerDependency
. The clearest documented description of how recursive peerDependencies should work is in the Yarn PnP Formal Guarantees.
ember-source
provides many "virtual" packages like @ember/component
. If they were real packages, they would be peerDependencies
, but having non-real packages in package.json is likely to result in errors. Pedantically, they can be listed in externals instead. In practice, they are a well-known set that we can always handle correctly automatically, so it's not very important whether an addon includes them in externals.
Some addons optionally use another addon if it happens to be available in the app. In order to resolve such a dependency, we really need Optional Peer Dependencies.
NPM doesn't have a concept of optional peer dependencies. It has "optional dependencies", but they are something different and pretty useless.
Yarn did an RFC for optional peer dependency support. It is basically compatible with NPM, with the only caveat being that if you use NPM you may see a spurious warning at install time. As non-actionable peerDependency warnings are rife throughout the NPM ecosystem this doesn't seem like a big deal.
V2 Packages are allowed to use optional peer dependencies as described in the Yarn RFC.
Our own tooling, like ember-cli-dependency-checker, we can make sure the warnings respect the Yarn standard.
This RFC is focused heavily on addons, because that is the area that is most critical to standardize. Publishing addons to NPM in V2 format has major benefits:
- build performance: there is much less work to do at app build time, and many
dependencies
of addons can becomedevDependencies
of addons, resulting in smallernode_modules
and fasternpm install
. - tool integration: VSCode, Typescript, SCSS, etc will all understand your code better when the dependencies are in V2 format. Things like "jump-to-definition" will work.
- Embroider stability:
@embroider/compat
needs to use heuristics and some addon-specific rules to compile V1 addons into V2. This is necessarily more fragile than having addons published natively in V2. The first step in stabilizing Embroider for mainstream adoption is standardizing on this new addon format.
In contrast, apps are not published to NPM. So where would they use V2 publication format?
During the build process for an app, it will first build from its authoring format to the standard v2 package format. At that point, the whole project is just a collection of standard v2 packages with well-defined semantics, and we can confidently treat that stage in the build pipeline as supported public API.
The benefit of this approach is that we can separately evolve authoring formats and last-stage packaging tools, while keeping a stable interface between them. The stable interface is designed to leverage general-purpose ECMA-spec-compliant features wherever practical, which makes it a rich target. For more detail on Embroider's three-stage build pipeline see the README.
v2-formatted apps do differ in a few ways from v2-formatted addon, as described in the following sections.
Several features in the v2 addon format are designed to be consumed by the app. These features aren’t appropriate in an app, because that is the end of the line — a v2-formatted app should be understandable by general-purpose Javascript tooling and have very little implicit Ember-specific build semantics left.
Features that apps may not use include:
- the
implicit-*
keys in Ember package metadata. - the
app-js
key in Ember package metadata - the
build
key in Ember package metadata. We should consider updating the authoring format so that apps can use a build file with the standard package hooks, because that makes a lot of sense. But it’s not appropriate in the v2 format (which is a publication format), and this change can be a separate RFC, and it will be an easier RFC after landing this one. - automatic inclusion of resolvable types (components, services, etc) from the Own Javascript of all Active Dependencies and the app itself.
- the
public-assets
key in Ember package metadata.
All these features can appear in v2 addons, and the app ensures each one is represented by standards-compliant Javascript within the app’s own code. To illustrate with some examples, the V2 format for an app (as already implemented in Embroider) includes:
<script>
tag(s) in index.html and tests/index.html that ensureimplicit-scripts
andimplicit-test-scripts
of all active addons are already accounted for.<link rel="stylesheet">
tag(s) in index.html and tests/index.html that ensureimplicit-styles
andimplicit-test-styles
are accounted for.- actual Javascript
import
statements within the app's code that ensureimplicit-modules
andimplicit-test-modules
are accounted for - actual Javascript
import
statements and AMDdefine
calls that handle automatic inclusion of resolvable types that cannot be statically ruled out.
There are also a few V2 package features only supported in apps. These are mostly of interest only to people working within ember-cli and/or embroider to implement new packaging tools. Each of these is a property in Ember package metadata:
-
rootURL
: has the same meaning asrootURL
inconfig/environment.js
in a standard Ember app. -
assets
: a list of relative paths to files. The intent ofassets
is that it declares that each file in the list must result in a valid URL in the final app.The most important assets are HTML files. All
contentFor
has already been applied to them. (Remember, we’re talking about the publication format that can be handed to the final stage packager, not necessarily the authoring format.) It is the job of the final stage packager to examine each asset HTML file and decide how to package up all its included assets in a correct and optimal way, emitting a final result HTML file that is rewritten to include the packaged assets.Note that packagers must respect the HTML semantics of
<script type="module">
vs<script>
vs<script async>
. For example: don’t go looking forimport
in<script>
, it’s only correct in<script type="module">
File types other than HTML are allowed to appear in
"assets"
. The intent is the same (it means these files must end up in the final build such that they’re addressable by URLs). For example, a Javascript file in"assets"
implies that you want that JS file to be addressable in the final app (and we will treat it as a script, not a module, because this is for foreign JS that isn’t going through the typical build system. If you actually want a separate JS file as output of your build, useimport()
instead). This is a catch-all that allows things like your/public
folder full of arbitrary files to pass through the final stage packager.A conventional app will have an
"assets"
list that includeindex.html
,tests/index.html
, and all the files that were copied from/public
. -
template-compiler.filename
: the relative path to a module that is capable of compiling all the templates. The module exports :compile: (moduleName: string, templateContents: string) => string
that converts templates into JS modules.
-
template-compiler.isParallelSafe
: true if the template compiler can be used in other node processes -
babel.filename
: the relative path to a module that exports the app's babel config. -
babel.isParallelSafe
: true if thebabel
settings can be used in a new node process. -
babel.majorVersion
: the version of babel the app's settings were written for. Only 6 and 7 are supported at this time.
Unlike addons, an app’s Own Javascript is not limited to only ES latest features. It’s allowed to use any features that work with the config in babel.filename
. This is an optimization — we could logically require apps to follow the same rule as addons and compile down to ES latest before handing off to a final packager. But the final packager is going to run babel anyway, so we allow apps to do all their transpilation in that final single pass.
This RFC introduces the idea of Ember Language Standard
as a way to be explicit about exactly what Javascript dialect we support in shared packages, and exactly what extensions to Javascript are required to understand the complete semantics of Ember apps.
As of this writing, Ember language standard contains the following subset of ECMAScript:
- all of ECMAScript 2019
- with the caveat that if you use un-transpile-able features (like
Proxy
) that aren't supported in all browsers that Ember supports, your package won't support those browsers
- with the caveat that if you use un-transpile-able features (like
- decorators proposal currently in TC39 stage 2.
- Optional Chaining and Nullish Coalescing, which are stage 4 (completed) proposals slated for inclusion in es2020.
- A subset of dynamic import, a stage 4 (completed) proposal slated for inclusion in es2020, as detailed below.
We also incorporate the following extensions to Javascript. Our intent here is that this is the minimal set of things that aren't really expressible, even in principle, using Javascript itself:
-
handlebars templates may be imported as if they were ES modules. You get back an ES module with a default export containing an opaque value representing the compiled template. The only public-API for consuming the opaque value you get back is to pass it to Ember's
setComponentTemplate()
or set it as thelayout
property on anEmber.Component
. The.hbs
extension has lower priority than.js
, soimport './foo'
will favor./foo.js
over./foo.hbs
. -
CSS files may be imported as if they were ES modules. The
.css
extension is mandatory, unlike.js
and.hbs
it is never automatically inferred. We define no exported value. The meaning is: if you import a CSS file, the CSS file is guaranteed to be in the DOM before your module evaluates.The reason we define no exported value is that it's not clear we want to commit to one long-term standard. By not giving CSS a value in JS, we reserve the right to do so in the future if a web standard emerges. Users who want imported CSS to return a value are free to compile their CSS to actual JS modules before publishing their package.
-
JSON files may be imported as if they were ES modules. This is supported in Node and it's enabled by default in popular Javascript bundlers like Webpack, so it's common to encounter code on NPM that expects it to work. We interpret JSON as an ECMA module whose
default
export is the JSON object.
Dynamic import aka import()
is supported. However, we choose to support only a subset of possible syntax inside import()
. Since we're choosing a strict subset, we can safely expand it later as needed.
First we'll illustrate by examples:
// ALLOWED: string literal
import("./some-module")
// ALLOWED: template string with a static prefix containing
// a relative path
import(`./locale/${currentLocale}/messages`);
// ALLOWED: template string with a static prefix containing
// a complete package name
import(`some-package/${feature}`);
// ALLOWED: template string with a static prefix that clearly
// contains an absolute URL.
import(`https://example.com/components/${choice}`);
// FORBIDDEN: any syntax that is not a string literal or template string
import(arbitraryExpression());
// FORBIDDEN: template string that does not uniquely identify a package
import(`@ember/${which}`);
To be more precise, the only supported syntax inside import()
is:
- a string-literal
- or a template string that begins with a static prefix which falls into one of these allowed cases:
- the static prefix matches
/(\w+:)?\/\//
- this detects absolute URLs, both ones with protocols (starting with
http://
) and ones that are protocol-relative (starting with//
). - in this case, Embroider will leave your
import()
alone so you can access the browser's implementation ofimport()
. You are responsible for the contents of the URL, it is beyond the scope of the Embroider build.
- this detects absolute URLs, both ones with protocols (starting with
- the static prefix starts with
@
and contains at least two/
- this detects a namespaced NPM package name. If the package can't be resolved or it contains zero files that match your pattern, we emit a static build error.
- the static prefix does not start with
@
and contains at least one/
- this covers patterns that start with a non-namespaced NPM package names or a relative path. If the pattern matches zero files, we emit a static build error.
- the static prefix matches
Any other syntax is a static build error.
The rationale for this chosen subset is that we want it to be possible to introduced controlled, intentional dynamism, without accidentally blowing holes in the static analysis of whole programs. For example, if we simply allowed any non-statically-resolvable module specifier to silently become a runtime import()
, it would mask many bugs that would otherwise be caught statically at build time. You can opt-in to fully-dynamic runtime import()
, but you need to say so in a way that is statically legible so we can distinguish it from accidents.
The @embroider/compat
package exists to compile V1 packages to V2. This allows @embroider/core
to always assume V2 packages as input, so we don't need to wait until every addon is natively available in V2 before we start getting the benefits of Embroider. However, there is still an incentive to convert as many addons as possible to V2, because they build faster and they will be more stable (the v1-to-v2 compilation isn't flawless, we need heuristics and package-specific rules to deal with some dynamic addon behaviors).
It also needs to be possible for an addon published as V2 to work in existing apps on existing ember-cli versions. This is enabled by:
ember-auto-import
, which serves as a high-fidelity polyfill for importing directly from NPM. V2 addons natively support importing from NPM, but they should depend onember-auto-import
so those imports will have the same meaning when used in classic ember-cli.@ember/macros
, which shall provide correct semantics regardless of whether the package using them is published as V1 or V2 and regardless of whether the build is being done by classic ember-cli or Embroider. Native V2 packages under Embroider can alway use macros, without an explicit dependency on@ember/macros
, but they should include the dependency so that macros will work in classic ember-cli.- ember-cli already supports a
main
property in underember-addon
and has supported it for many versions. This allows an addon to put its classicindex.js
file in a place other than the package's truemain
. This means that V2 addons can have their runtimeindex.js
asmain
, and should pointember-addon.main
to aclassic.js
file. Theclassic.js
file shouldrequire
andexport
a compatibility shim library that we will provide. The compatibility shim will have the classic methods liketreeForAddon
,treeForPublic
that take the V2-formatted features and present them in a way that classic ember-cli will understand. Since V2 packages are much more static than V1 packages, this shim is expected to not be very complicated.
This RFC should have no direct impact on what app authors need to learn. They keep using addons the same way they always have. Future RFCs that take Embroider mainstream will have impact, but that can be discussed then.
The impact on addon authors is more significant. This design is fully backward compatible, and the intention is that all existing addons continue to work (some with worse compatibility hacks than others in the v1-to-v2 compiler). But there will be a demand for addons published in v2 format, since it is expected to result in faster build times. My prediction is that people who are motivated to get their own app build times down will send a lot of PRs to the addons they’re using.
In many cases, converting addons to v2 makes them simpler. For example, today many addons use custom broccoli code to wrap third-party libraries in a fastboot guard that prevents the libraries from trying to load in Node (where they presumably don’t work). In v2, they can drop all that custom build-time code in favor of a macro-guarded importSync
.
This design does not advocate loudly deprecating any v1 addon features. Doing that all at once would be unnecessarily disruptive. I would rather rely on the carrot of faster builds and Embroider stability than the stick of deprecation warnings. We can choose to deprecate v1 features in stages at a later time.
We should release an officially-supported package (tentatively named @embroider/addon
) that provides tooling for addon authors who want to publish a V2 package that is backward compatible in non-embroider apps. This package's documentation is a natural starting point for teaching developers what to do.
Embroider effectively supersedes both the Packager RFC and the Prebuilt Addons RFC. So both of those are alternatives to this one.
Packager creates an escape hatch from the existing ember-cli build that is supposed to provide a foundation for many of the same features enabled by this design. The intention was correct, but in my opinion it tries to decompose the build along the wrong abstraction boundaries. It follows the existing pattern within ember-cli of decomposing the build by feature (all app javascript, all addon javascript, all templates, etc) rather than by package (everything from the app, everything from ember-data, everything from ember-power-select, etc), which puts it into direct conflict with the Prebuilt Addons RFC.
The API that packager provides is also incomplete compared with this design. For example, to take the packager output and build it using Webpack, Rollup, or Parcel still requires a significant amount of custom code. Whereas taking a collection of v2 formatted Ember packages and building them with any of those tools requires very little Ember-specific code.
The prebuilt addons RFC addresses build performance by doing the same kind of work-moving as this design. Addons can do much of their building up front, thus saving time when apps are building. But it only achieves a speedup when apps happen to be using the same build options that addons authors happened to publish. This design takes a different approach that preserves complete freedom for app authors to postprocess all addon Javascript, including dead-code-elimination based on the addon features their app is using. The prebuilt addons RFC also doesn’t attempt to specify the contents of the prebuilt trees — it just accepts the current implementation-defined contents. This is problematic because shared builds artifacts are long-lived, so it’s worth trying to align them with very general, spec-compliant semantics.
-
There is a SPEC draft in the Embroider repo that predates this one, but covers a broader scope. Where this document contradicts SPEC.md, this document takes precedence and SPEC.md needs to be updated. But SPEC.md covers a broader scope, including the disposition of the other build hooks that will be handled in future RFCs.
-
The definitive list of Ember package metadata fields is declared in AppMeta and AddonMeta interfaces. Each one is documented in an Appendix in SPEC.md.