The perils of dynamic component invocation

I’m rendering a lot of compoennts in my app using the {{component}} helper. For example this pattern:

{{#each this.myData as |item|}}
  {{component (choose-component item.type)}}
{{/each}}

Or this pattern:

{{#each this.myData as |item|}}
  {{component (concat "some-prefix/' item.type)}}
{{/each}}

There are a couple of reasons for using this pattern:

  • mixed collections from the server that we want to render independently
  • managing UI states (think separate component for login/logout button or play/pause button, for example)

I really like this pattern because my component choosing strategy is essentially a key-value lookup and my components are a bunch of dumb, template-only components.

But I’m concerned that the dynamic string generation and overuse of the {{component}} helper introduces footguns for future treeshaking.

Can anyone comment on this? Maybe @ef4 has some insight from Embroider land?

1 Like

Yes, we need to do an RFC on how to close the “dynamic component hole”.

But that doesn’t mean all uses of the component helper are bad and I wouldn’t want people to start refactoring everything until we can provide a more pleasant solution to refactor toward.

Today it is somewhat laborious and awkward. Using public API, you can only get a handle on a component definition from inside a template, not from inside Javascript. Which means your mapping of allowed components needs to be expressed in a template:

{{! app/templates/components/feed-items.hbs }}
{{yield (hash 
  // these uses of the component helper are all
  // statically analyzable, because they use
  // string literal arguments.
  post={{component "feed-components/post"}}
  photo={{component "feed-components/photo"}}
  comment={{component "feed-components/comment"}}
 )}}

Which you can then use something like:

<FeedItems as |feedItems|>
  {{#let (get feedItems this.itemType) as |ItemComponent|>
    // angle brackets can invoke component definitions
    // and aren't supposed to resolve strings into
    // definitions, so this is statically safe (it 
    // can't access a component that wasn't 
    // already loaded by somebody else).
    <ItemComponent />
  {{/let}}
</FeedItems>

This would all be much nicer if we were allowed to directly import components into Javascript and then invoke them. Because that, combined with the rules already laid out in RFC 507 would allow:

import { importSync } from '@ember/macros';
export default class extends Component {
  get ItemComponent() {
    return importSync(`./feed-components/${this.itemType}`).default;
  }
}
<this.ItemComponent />

Which still lets you write very dynamic code, but in a way that allows us to statically know what set of components might get used by that spot.

It’s also even possible to lazy load the components in a data-driven way, as long as you don’t mind dealing with the asynchrony:

import { restartableTask } from 'ember-concurrency-decorators';
export default class extends Component {
  init(...args) {
    super.init(...args);
    this.loadComponent.perform(this.itemType);
  }

  @restartableTask
  loadComponent = function*(itemType) {
    let module = yield import(`/feed-components/${itemType}`);
    this.set('ItemComponent', module.default);
  }
}
{{#if this.loadComponent.isRunning }}
  <LoadingSpinner />
{{else}}
  <this.ItemComponent />
{{/if}}

This kind of behavior is basically implied by the strict mode RFC, since it implies that components can be imported in JS and then provided in the ambient scope of a template. That RFC also suggests an alternative way to solve the problem we’re solving here (importing components into templates rather than importing them into Javascript), but I have emphasized the Javascript case because it composed so well with dynamic imports.

1 Like

I should clarify, when I said:

They’re statically analyzable as long as you haven’t customized the resolver. Which is a thing you should never, ever do anyway.

Thanks for the reply and code examples @ef4! “Partially invoking” in a “provider” component or importing and setting via JS to get static analysis makes a lot of sense. I may be able to use the first pattern already as a way to safelist components.

The only follow up I have is about the partial invocation in the provider component. Is there a cost to that that is worth considering? Especially when all the safelisted components could be used, but may not be.

Yes, there is some cost. Each time you use this pattern to invoke, you’re running the component helper for each of the possible components. It’s probably not a lot of cost, but it’s there. It certainly feels bad that the cost is O(N) in the number of possible components, even if the constant factor is small.

I could imagine trying to optimize that by caching the component definitions in a service, etc. But at that point it’s probably cleaner to use private API to get the component definitions directly in Javascript. (With the usual caveat that you should expect to spend time fixing it when you upgrade Ember.)