Ember.js: [META] Embroider Readiness

Created on 18 Aug 2020  ·  17Comments  ·  Source: emberjs/ember.js

Today, Embroider in compatibility mode can be used in new applications and in many existing applications. It is more difficult to use Embroider in staticComponents, mode, which is necessary to get the benefit of splitAtRoutes mode.

Issue #501 on the Embroider repository tracks the remaining issues needed to stabilize Embroider as a part of an Ember.js release.

This issue tracks the steps that we need to take before people can practically use Ember with Embroider as a supported option with route-based code splitting ("Embroider readiness"). While there are many granular ways to use Embroider (including a compatibility mode that offers few concrete benefits but is important for migrating Ember itself to Embroider by default), this issue is focused on the ability to use Embroider with a normal Ember application and get benefits from route-based code splitting.

Technical Requirements

As described in the Embroider README, in order to enable route-based code splitting (splitAtRoutes), an application must be able to enable these flags:

  • [ ] staticAddonTestSupportTrees
  • [ ] staticAddonTrees
  • [ ] staticHelpers
  • [ ] staticComponents

If an addon or application cannot work in the presence of these flags, they are using "classic dynamic features".

MVP: Deprecate and replace (component dynamicString)

For the first target of Embroider readiness ("MVP"), we need to eliminate the most common roadblocks that we've found when trying to enable the static flags in real-world applications.

For the MVP milestone, it is a non-goal for all ecosystem addons support these flags.

Instead, the goal is for applications to have a reasonable transition path to splitAtRoutes, and that it's possible to build substantial, non-trivial applications in that mode. That means that all addons included in the default blueprint must have migrated away from classic dynamic features. It also means that addons that are frequently used in real-world applications, such as Ember concurrency, must not use classic dynamic features.

staticComponents

This is the most important static flag, and its requirement poses the most substantial obstacle to the MVP target.

In order to enable staticComponents, an application (including its addons) must be free of all uses of (component dynamicString).

Importantly, applications and their addons are allowed to use (component "static string") in staticComponents mode.

In practice, this means that we will need to migrate away from patterns like this:

{{#let (component this.componentName) as | Component |}}

Addons that currently take strings as part of their public API will need to, instead, take components, which would mean that this addon would need to migrate to an approach that required their users to supply the component to invoke, rather than a string.

This is a particularly thorny situation, since this.component is defined as this.componentName = scaffolding/${dasherize(csId!)}/${dasherize(this.args.feature)}. Situations like this are precisely the reason that we will need to think about and carefully roll out a transition strategy.

At minimum, in order to allow addons to migrate away from (component dynamicString), we will need to create a new version of the component keyword that does not allow invocation of dynamic components.

Additionally, we need to fix a bug that inadvertantly allows component invocation with angle-bracket syntax to work the same way as the dynamic component keyword. This needs to be done soon, because once people begin to try to migrate away from (component dynamic), there's a high likelihood that people will accidentally migrate to <dynamic>, observe that it works, and move on.

Action items:

  • [ ] Design and release a new version of (component) that only supports static strings (requires RFC)
  • [ ] Fix the bug that allows angle bracket invocation to behave like dynamic component invocation in Ember (bugfix, perhaps an intimate API)

staticHelpers

The staticHelpers flag does not reduce the expressiveness of Ember templates, but it means that the entire list of an application's helpers will not be available in the set of modules in the loader.

We assume that this will have breaking effects on Ember applications, but many fewer problems than staticComponents. We don't currently expect staticHelpers to affect the MVP target, but that may change as we attempt to upgrade applications to splitAtRoutes. If that happens, we will need to evaluate the problematic use-cases and consider new APIs to facilitate migration.

staticAddonTrees and staticAddonTestSupportTrees

The current assumption is that these flags are compatible with most idiomatic Ember apps and addons, and therefore do not pose any substantial problems for the requirements of the MVP target.

Next Steps

This issue is intended to remain as a tracking issue after we achieve the MVP target. After the MVP target, we will need to facilitate a complete ecosystem migration and improve the ergonomics of dynamic use-cases (which retaining support for code splitting). Template imports is likely to help achieve these goals.

Help Wanted

Most helpful comment

@NullVoxPopuli @jherdman Awesome!

In general, the way to think about what people would need to do instead of (component dynamicString) is that they need the import or (component "staticString") to go somewhere.

Options include:

  • the caller of the component will do (component "staticString")
  • the code with the dynamism will enumerate the possibilities, put them into a map, and use (get componentMap dynamicString) to get them out

Now you might be thinking: how could (get components dynamicString) be any better than (component dynamicString)? The answer is that componentMap had to be built somewhere, and the rules apply recursively.

So let's say you're writing a component that allows you to write <InputField @type="text" /> or <InputField @type="checkbox" /> (like <input> in HTML).

Your template for input-field would look something like this:

{{#let (hash text=(component "inputs/text-field") checkbox=(component "inputs/checkbox")) as |components|}}
  {{component (get components @type)}}
{{/let}}

When you write the code this way, Embroider can see that it only needs to include two components in the bundle. Had you written it this way:

{{component (concat "inputs/" @type "-field")}}

(yes, things like that are surprisingly common)

then Embroider cannot easily analyze the code and limit the components that will be included in the bundle. This is even worse if you did the work in JavaScript (which is also very common):

export default class extends Component {
  get innerComponent() {
    return `inputs/${this.args.type}-field`;
  }
}

with this template:

{{component this.innerComponent}}

It's a small tweak, but it makes it possible for Embroider to determine which components are actually used.

All 17 comments

One way to ease the cost of transition would be to allow addons to indicate statically which of their arguments corresponds to a static string passed in by the caller of the component

As a spitball (where better-component is a placeholder for the name of the static component keyword).

{{better-component @arg staticString=true}}

This would allow addons to indicate that a particular "dynamic invocation" only works if the caller supplies a static string.

We should only do this if a lot of dynamic component cases are, in practice, caused by a straight-forward "string argument passed to an addon".

Idk if this is on topic for this issue, but I've been periodically trying to figure out full staticness over on emberclear, and have been keeping a paper trail of issues and their solutions.

https://github.com/NullVoxPopuli/emberclear/pull/784

So, if people run in to issues, maybe the Abe documentation can help out? Idk

Also, I'm super excited for embroider, and already have big plans once full staticness is achieved
https://github.com/emberjs/rfcs/issues/611

Our application is a dynamic content delivery system. Content creators assemble content out of "Activity Elements", each of which has a corresponding Ember Component, the name of which is defined on an Ember Data model. The heart of this system more or less looks like this:

// example model definition
export default class TextElement extends Model {
  _componentName = 'text-element';
}
  {{#each (sort-by "position" @activityElements) as |activityElement|}}
    {{component (get activityElement "_componentName")}}

My reading of the above suggests that this is a (component dynamicString) scenario. Is that accurate?

My reading of the above suggests that this is a (component dynamicString) scenario. Is that accurate?

What is @activityElements and where is it defined?

This would be an array of Ember Data Model instances passed into a controller component.

We also invested a bit heavily into dynamic components, FYI. I asked about it here last year: https://discuss.emberjs.com/t/the-perils-of-dynamic-component-invocation/16784.

So do we. We literally don't know the components that are going to be used in the app in advance. They sit in a database (which of course makes them changeable), are compiled on the backend and are sent on-demand to the frontend. Not being able to use the dynamic component helper for us is the same like not being able to for-each over an array. I really hope there is some way forward for use cases like this one. I definitely wouldn't care about code splitting/tree shaking if my app doesn't work because I don't have the dynamism I need.

Could a map be used of all valid components for that scenario?

For example:

{{#let (hash
   Foo=(import 'path/to/foo')
   Etc=...
) as |validComponents|
}}
  {{component (get validComponents @someDynamicValue)}}
{{/let}}

?

Like, you can't truely have full dynamic components, because you can only render what's in your app -- creating a list to choose what to render would also help in debugging, too. "Oh, this value wasn't one of the valid components"

Could a map be used of all valid components for that scenario?

This would definitely cover our use case.

@NullVoxPopuli @jherdman Awesome!

In general, the way to think about what people would need to do instead of (component dynamicString) is that they need the import or (component "staticString") to go somewhere.

Options include:

  • the caller of the component will do (component "staticString")
  • the code with the dynamism will enumerate the possibilities, put them into a map, and use (get componentMap dynamicString) to get them out

Now you might be thinking: how could (get components dynamicString) be any better than (component dynamicString)? The answer is that componentMap had to be built somewhere, and the rules apply recursively.

So let's say you're writing a component that allows you to write <InputField @type="text" /> or <InputField @type="checkbox" /> (like <input> in HTML).

Your template for input-field would look something like this:

{{#let (hash text=(component "inputs/text-field") checkbox=(component "inputs/checkbox")) as |components|}}
  {{component (get components @type)}}
{{/let}}

When you write the code this way, Embroider can see that it only needs to include two components in the bundle. Had you written it this way:

{{component (concat "inputs/" @type "-field")}}

(yes, things like that are surprisingly common)

then Embroider cannot easily analyze the code and limit the components that will be included in the bundle. This is even worse if you did the work in JavaScript (which is also very common):

export default class extends Component {
  get innerComponent() {
    return `inputs/${this.args.type}-field`;
  }
}

with this template:

{{component this.innerComponent}}

It's a small tweak, but it makes it possible for Embroider to determine which components are actually used.

I also want to spell out connections to two other in-flight features more carefully.

  1. Template Imports
  2. Using Component Classes as Invokables

If we rewrite the previous example using those two features, it looks like:

---
import TextField from "./text-field";
import Checkbox from "./checkbox";
---
{{#let (hash text=TextField checkbox=Checkbox) as |components|}}
  {{component (get components @type)}}
{{/let}}

This has the nice property of eliminating a special rule that Embroider would have to understand, and making the user model for code splitting even more about modules.

Using Component Classes as Invokables

Since RFC #481, a component class has been a fully self-contained unit that has all the information that is needed for the Glimmer VM to invoke it as a component.

Note: This is not just true about classes that inherit from @ember/component or @glimmer/component. Since RFC 481, Ember's definition of "component" is "an object that is associated with a template and component manager", which includes custom components like hooked-components.

Since RFC #432 (contextual helpers and modifiers) and RFC #496 (handlebars strict mode), the design for the future of the Ember's template syntax is: expressions containing helpers, components or modifiers can be invoked as helpers, components or modifiers.

The implication of the current collection of approved RFCs is that <SomeComponentClass /> (or <this.componentClass> where this.componentClass resolves to a component class) would "just work". It's also the plan of record for the implementation work.

That said, for clarity, we should create a new RFC that explicitly specifies this behavior.

Specifying a list of valid components in HBS somewhere is doable for us, but there's a couple caveats worth calling out:

  1. Currently, we use the JS solution @wycats pointed out. Doing the same in an HBS provider is doable, but as we all know logic in HBS is a bit more arduous. It's also _much_ harder to unit test that a provider component yields the right component than a util function returning the right strings.
  2. This doesn't exist in JS (with public API anyway) afaik, but using directory structure to our advantage would be very nice here. E.g.:

    {{#let (lookup-directory "components/inputs/") as |components|}}
    {{/let}}
    

    This kind of thing may end up being easier to maintain over time if you have known polymorphic types grouped together by directory structure.

In any case, I feel like dynamic components needs its own issue to discuss rather than taking over this one 🙈 😄

In any case, I feel like dynamic components needs its own issue to discuss rather than taking over this one 🙈 😄

I agree, and will open one shortly in the RFC repo, and post a link here.

@mehulkar @wycats @jherdman what about allowing users to just import their list of dynamically invocable components? Seems much easier to follow than a helper that includes an extra level of indirection.

import Component1 from './dynamic/component-1';
import Component2 from './dynamic/component-2';
import Component3 from './dynamic/component-3';

export default class extends Component {
  get innerComponent() {
    switch(this.args.type) {
      case 'one':
        return Component1;
      case 'two':
        return Component2;
      case 'three':
        return Component3;
      default:
        // handle invalid type
    }
  }
}

Then the template can just do: {{#let (component this.innerComponent) as |DynamicComponent|}} or something similar.

I'd much prefer something like this that is easier search/read/analyze, and easily detect where these components are being used. Thoughts?

@Samsinite yeah i think that's the idea. The helper in HBS would mostly be syntactic sugar and useful for template-only components.

@Samsinite that's basically what I proposed as a near-term fix (before template imports).

It would require us to allow component classes to be invocable, which is what I was talking about in this comment.

Let's go do it!

Ah, that makes sense for template only components, sorry for mis-understanding :). I also like the syntax for template only components of:

---
import TextField from "./text-field";
import Checkbox from "./checkbox";
---
{{#let (hash text=TextField checkbox=Checkbox) as |components|}}
  {{component (get components @type)}}
{{/let}}

as well, in-fact, I'd probably use it for template only and js backed components, depending on what others on the team find easier to read.

Was this page helpful?
0 / 5 - 0 ratings