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.

3 Likes

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.)

1 Like

Updating this topic because things have gotten much better since it was last discussed:

Since Ember 3.25, you can directly invoke plain old component classes (as long as those components use co-located templates – which you should be doing anyway because it’s an easy codemod from the old pattern and it’s nicer to work with).

So for example, you can import components into your JS:

import First from './first';
import Second from './second';

export default class extends Component {
  get whichComponent() {
    if (this.useFirst()) {
      return First;
    } else {
      return Second;
    }
  }
}

And invoke them:

<this.whichComponent />

This unlocks a lot of nicer patterns for dynamic component invocation from a fixed set of possible components.

It’s also possible to combine ember-auto-import and embroider macros to dynamically load components out of a v2-formatted addon:

import { importSync } from '@embroider/macros';

export default class extends Component {
  get whichComponent() {
    return importSync(`my-feed-items-addon/${this.which}`).default;
  }
}

If you’re using embroider with staticComponents enabled, you can do the same thing for components out of a subdirectory of your own app.

You can also use ECMA-standard import() instead of importSync() where you can absorb the asynchrony, and then they truly load lazily and you’ll only download the components that get used.

2 Likes

This is really great, appreciate the update. The one thing that still seems to be “missing” in this case is the ability to curry arguments to a component in javascript (e.g. this RFC). Does it seem like that will definitely land at some point and it’s just a matter of overcoming technical/design challenges?

I agree that is a remaining gap. I don’t necessarily think we need to duplicate template functionality in javascript however, because I’d rather see a feature like https://github.com/emberjs/rfcs/pull/779 become standard that makes it easier to compose JS and HBS together:

import SomeComponent from './some-component';

let CurriedComponent = <template>
  <SomeComponent @myCurriedArg={{whatever}} @otherArg={{@otherArg}} ...attributes />
</template>

export default <template>
  {{yield CurriedComponent}}
</template>

In this case, the missing feature to make currying nice is the ability to splat all arguments (including block arguments) onto a component invocation. It would do for @ args the same thing that ...attributes does for html attributes.

Ah I didn’t realize that RFC would make currying possible but that example makes perfect sense, that’s great. Would love to see splarguments land. We’ve been using ember-spread for a few things but that’s not going to last much longer. Anyway, thanks!