Skip to content

Latest commit

 

History

History
252 lines (177 loc) · 9.63 KB

replacing-component-helper.md

File metadata and controls

252 lines (177 loc) · 9.63 KB

Replacing the Component Helper

Using the {{component}} helper can prevent your app or addon from being statically analyzed, which will prevent you from taking advantage of staticComponents and splitAtRoutes.

The exact rules for {{component}} are:

  • it's OK to pass a string literal component name like {{component "my-title-bar"}}
  • it's OK to pass a value wrapped in the ensure-safe-component helper from @embroider/util, like {{component (ensure-safe-component this.titleBar) }}. But make sure you understand what ensure-safe-component itself is doing. Sprinkling existing code with ensure-safe-component without paying attention to the deprecation messages it emits will not make your app work in Embroider.
  • any other syntax in the first argument to {{component ...}} is NOT OK

The following sections explain what to do in the common scenarios where you might have unsafe usage of the {{component}} helper.

When you're invoking a component you've been given

Here's an example of a component that accepts an optional @titleBar= argument:

import Component from '@glimmer/component';

export default class extends Component {
  get titleBar() {
    return this.args.titleBar || 'default-title-bar';
  }
}
{{component this.titleBar}}

The first step is to switch to angle bracket invocation:

<this.titleBar />

Now we eliminated the {{component}} helper. And this actually works, but with two big caveats:

  • it only works reliably on Ember versions before 3.23, because we might get passed a string, and invoking a string via angle brackets was an accidental behavior that was never intended, and it's fixed in that release.
  • and we haven't really solved the underlying problem, which is that we can sneakily convert strings into components in a way that lets them escape Embroider's analysis.

So we need another step. Add @embroider/util to your project, and use ensureSafeComponent:

import Component from '@glimmer/component';
import { ensureSafeComponent } from '@embroider/util';

export default class extends Component {
  get titleBar() {
    return ensureSafeComponent(this.args.titleBar || 'default-title-bar', this);
  }
}
<this.titleBar />

This now works even on newer Ember versions. If the user passes a string, it emits a deprecation warning while converting the value to an actual component definition so the angle bracket invocation works. This will help your users migrate away from passing strings to your component.

Notice also that if the user doesn't provide a component, we will trigger the deprecation warning by passing our own string "default-title-bar" into ensureSafeComponent. So we need one more step to clear this deprecation (and make our code truly understandable by embroider). Import the component class instead:

import Component from '@glimmer/component';
import { ensureSafeComponent } from '@embroider/util';
import DefaultTitleBar from './default-title-bar';

export default class extends Component {
  get titleBar() {
    return ensureSafeComponent(this.args.titleBar || DefaultTitleBar, this);
  }
}
<this.titleBar />

On old Ember versions (< 3.25), when ensureSafeComponent sees a component class, it converts it into a component definition so it can be safely invoked. On newer Ember versions, it does nothing because component classes are directly invokable.

Caution: old-style components that have their template in app/templates/components instead of co-located next to their Javascript in app/components can't work correctly when discovered via their component class, because there's no way to locate the template. They should either port to being co-located (which is a simple mechanical transformation and highly recommended) or should import their own template and set it as layout as was traditional in addons before co-location was available.

When you're passing a component to someone else

Here's an example <Menu/> component that accepts a @titleBar=. When the author of <Menu/> follows the steps from the previous section, if we try to call it like this:

<Menu @titleBar='fancy-title-bar' />

we'll get a deprecation message like

You're trying to invoke the component "fancy-title-bar" by passing its name as a string...

The simplest fix is to add the {{component}} helper:

<Menu @titleBar={{component 'fancy-title-bar'}} />

This is one of the two safe ways to use {{component}}, because we're passing it a string literal. String literals are safe because they are statically analyzable, so Embroider can tell exactly what component you're talking about.

But if instead you need anything other than a string literal, you'll need a different solution. For example, this is not OK:

<Menu @titleBar={{component (if this.fancy 'fancy-title-bar' 'plain-title-bar')}} />

You can refactor this example into two uses with only string literals inside {{component}}, and that makes it OK:

<Menu @titleBar={{if this.fancy (component 'fancy-title-bar') (component 'plain-title-bar')}} />

But if your template is getting complicated, you can always move to Javascript and import the components directly:

import Component from '@glimmer/component';
import FancyTitleBar from './fancy-title-bar';
import PlainTitleBar from './plain-title-bar';

export default class extends Component {
  get whichComponent() {
    return this.fancy ? FancyTitleBar : PlainTitleBar;
  }
}
<Menu @titleBar={{this.whichComponent}} />

Note that we didn't use ensureSafeComponent here because we already stipulated that <Menu/> is itself using ensureSafeComponent, and so <Menu/>'s public API accepts component classes or component definitions. But if you were unsure whether <Menu/> accepts classes, it's always safe to run them through ensureSafeComponent yourself first (ensureSafeComponent is idempotent).

When you need to curry arguments onto a component

A common pattern is yielding a component with some curried arguments:

{{yield (component 'the-header' mode=this.mode)}}

In this particular example, we're using a string literal for the component name, which makes it OK, and you don't need to change it.

But what if you need to curry arguments onto a component somebody else has passed you?

{{yield (component this.args.header mode=this.mode)}}

Because we're only adding a mode= argument to this component and not invoking it, we can't switch to angle bracket invocation. Instead, we can wrap our component in the ensure-safe-component helper from the @embroider/util package:

{{yield (component (ensure-safe-component this.args.header) mode=this.mode)}}

This works the same as the Javascript ensureSafeComponent function, and by appearing directly as the argument of the {{component}} helper, Embroider will trust that this spot can't unsafely resolve a string into a component.

When you're matching a large set of possible components

Another common pattern is choosing dynamically from within a family of components:

import Component from '@glimmer/component';

export default class extends Component {
  get whichComponent() {
    return `my-app/components/feed-items/${this.args.model.type}`;
  }
}
{{component this.whichComponent feedItem=@model}}

You can replace this with native import() or the importSync() macro, because they support dynamic segments (for full details on what exactly is supported, see "Supported subset of dynamic import syntax" in the Embroider V2 Package RFC.

In this case, we're refactoring existing synchronous code so we can use importSync:

import Component from '@glimmer/component';
import { importSync } from '@embroider/macros';
import { ensureSafeComponent } from '@embroider/util';

export default class extends Component {
  get whichComponent() {
    let module = importSync(`./feed-items/${this.args.model.type}`);
    return ensureSafeComponent(module.default, this);
  }
}
<this.whichComponent @feedItem={{@model}} />

This code will cause every module under the ./feed-items/ directory to be eagerly included in your build.

To instead lazily include them, refactor to use asynchronous import() instead of importSync. BUT CAUTION: using import() of your own app code is one of the few things that works only under Embroider and not in classic builds, so don't do it until you have committed to Embroider.

When using one-off components in tests

If you find yourself defining custom, one-off components to be used in your tests, you might have been using a syntax like this:

import { setComponentTemplate } from '@ember/component';
import Component from '@glimmer/component';

test('my test', async function (assert) {
  class TestComponent extends Component {}

  setComponentTemplate(hbs`Test content: {{@message}}`, TestComponent);

  this.owner.register('component:test-component', TestComponent);

  await render(hbs`
    <MyComponent @display={{component 'test-component'}} />
  `);
});

This will fail, as test-componentcannot be statically found. Instead, you can directly reference the component class:

import { setComponentTemplate } from '@ember/component';
import Component from '@glimmer/component';

test('my test', async function (assert) {
  class TestComponent extends Component {}

  setComponentTemplate(hbs`Test content: {{@message}}`, TestComponent);

  this.testComponent = TestComponent;

  await render(hbs`
    <MyComponent @display={{this.testComponent}} />
  `);
});