How do you register co-located components?

Background

We need to support different versions of our application. What constitues a unique version? Anytime we have an ember route that is mostly the same but with a some differences we customize any of the files that make up that screen including: Ember Data models, components, helpers, routes, controllers, route templates, i.e., anything.

Our current approach is to:

  1. Call a node script from ember-cli-build.js that creates an array of all the version files and writes them to an ignored file, e.g., version-file-names.js. It looks something like:
export default [
  "./app/versions/version-1/components/component-1",
  "./app/versions/version-1/components/component-2",
  "./app/versions/version-1/controllers/controller-1",
  "./app/versions/version-2/components/component-1",
  "./app/versions/version-1/components/component-3",
  "./app/versions/version-1/controllers/controller-100",
  // ...more
];
  1. In an ember instance initializer:
  • Grab the version-file-names.js file, filter it down based on the version the application is being built for. The version is set in config/environment.js or on the process.
  • Grab the factory for each version file, e.g., const factory = appInstance.factoryFor(``auction-style:${fileName}``);.
  • Register the file on the app instance, e.g., appInstance.register(``${singularize(type)}:${fileName}\\``, factory.class);.

The Problem

This worked well until – in preparation for embroider and as a prerequisite to handling dynamic props passed to the template component helper – we started switching from classic component structure to co-located, nested components i.e., what was:

app/components/component-1.ts
app/templates/components/component-1.hbs

Is now:

app/components/component-1/index.ts
app/components/component-1/index.hbs

Arguably a much better structure, and if I understand correctly, the future of ember. But now we’re running into a few issues with handling versions of our applications:

  1. A component’s index.hbs file is compiled down to index.js. ember-cli-typescript throws a warning about this because the template’s index.js file is now sibling to the component’s TS file with the same name index.ts. Here’s the thread on it: Docs: add info about merged app directory · Issue #780 · typed-ember/ember-cli-typescript · GitHub. Somehow the co-located, nested components directly in app/components don’t throw this warning, but those in our app/versions/version-1/components folder do.
  2. Registering a co-located, nested component is non-trivial. Here is my non-working attempt. Basically I check to see if the component is template-only, if so, register it one way, if not, register it another way.
import ApplicationInstance from '@ember/application/instance';
import { singularize } from 'ember-inflector';
import VersionManagerService from '@auction-fe/base/services/version-manager';
// import { templateOnlyComponent } from '@glimmer/runtime';
// import { compile } from 'ember-template-compiler';
// @ts-expect-error
import { setComponentTemplate } from '@ember/component';

export function initialize(appInstance: ApplicationInstance): void {
  const config = appInstance.lookup('main:config');

  if (config.environment !== 'test') {
    console.log(appInstance);

    setStyleOverrides(appInstance);
  }
}

export default { initialize };

function addComponent(
  appInstance, 
  name, 
  { 
    ComponentClass = null, 
    template = null 
  }: { ComponentClass?: object | null, template?: object | null }
) {
  if (ComponentClass) {
    register(appInstance, 'components', name, { class: ComponentClass });
  }

  if (template) {
    register(appInstance, 'templates', `components/${name}`, { class: template });
  }
}

export function setStyleOverrides(appInstance: ApplicationInstance): void {
  const { versionDirectory, modulePrefix } = appInstance.lookup('main:config');
  const { version }: VersionManagerService = appInstance.lookup('service:version-manager');
  const fileNames: Array<string> = appInstance.factoryFor('version:file-names').class;
  let versionFileNames = fileNames.filter((fileName) => fileName.includes(`${versionDirectory}/${version}`));

  versionFileNames = versionFileNames.map((fileName) => fileName.replace(`${versionDirectory}/`, ''));

  return versionFileNames.forEach((fileName: string) => {
    const fileNameWithoutExtension = fileName.replace(/\.(ts|js|hbs)/, '')
    const componentRegEx = new RegExp('(.*)(components\/)(.*\/(template|index)$');
    const isComponent = componentRegEx.test(fileNameWithoutExtension);

    if (isComponent) {
      const isTemplate = /(\.hbs)$/.test(fileName);

      // NOTE
      // Components can be template only or template and TS/JS. We handle
      // the TS/JS registration at the same time as the template file, so we
      // don't need to process them here.
      if (!isTemplate) return;

      const fileNameWithoutComponentIndex = fileNameWithoutExtension.replace(/(.*components\/.*)\/index$/, "$1"); // 2.
      const templateString = appInstance.factoryFor(`version:${fileNameWithoutExtension}`).class;
      const templateModuleName = `${modulePrefix}/${fileNameWithoutComponentIndex}`;
      const isTemplateOnly = !versionFileNames.includes(fileName.replace('index.hbs', 'index.ts'));

      if (isTemplateOnly) {
        addComponent(
          appInstance,
          fileNameWithoutComponentIndex,
          {
            // ComponentClass: templateOnlyComponent(),
            template: templateString,
          },
        );
      } else {
        const componentClass = appInstance.factoryFor(`version:${fileNameWithoutExtension}`).class;
        addComponent(
          appInstance,
          fileNameWithoutComponentIndex,
          {
            ComponentClass: setComponentTemplate(templateString, componentClass),
          }
        );
      }
    } else {
      const factory = appInstance.factoryFor(`version:${fileNameWithoutExtension}`);
      const [type, ...filePath] = fileName.split('/');
      register(appInstance, type, filePath.join('/'), factory);
    }
  });
}

function register(appInstance: ApplicationInstance, type: string, fileName: string, factory?: { class: unknown }) {
  if (factory) {
    appInstance.register(`${singularize(type)}:${fileName}`, factory.class);
  }
}

Questions

  • How do we maintain a set of co-located, nested components outside the app directory without getting the ember-cli-typescript warning about a JS/TS file collision?
  • What is the correct way to register a co-located, nested component on an app instance?

Alternative Solutions

  • We have considered – and would very much like – to simply use broccoli and ember-cli-build.js file to merge our ‘version’ file tree into our app tree (the trees can and should match exactly except their parent directory name) when the application is built. But there is a giant problem with that: testing. For tests we need to be able to dynamically set the version per test. This requires all version files to be registered at build time.
  • Something else better/simpler?

This is not how it works in the standard build. Co-located templates go down a very different path than traditional standalone templates. Co-located templates get inlined into the corresponding Javascript modules and are never seen by the registry/container, the only thing that gets registered is always a Javascript module (if none exists, the template-only one gets synthesized so there’s a place to inline the template into).

This:

// app/components/thing.js
import Component from '@glimmer/component';
export default class extends Component {}
{{!-- app/components/thing.hbs }}
Hello world

Becomes:

// app/components/thing.js
import Component from '@glimmer/component';
import { hbs } from 'ember-cli-htmlbars';
import { setComponentTemplate } from '@ember/component';
export default setComponentTemplate(hbs`Hello World`, class extends Component {});

And in the template-only case, this:

{{!-- app/components/example.hbs }}
Hello world

Becomes:

// app/components/example.js
import templateOnlyComponent from '@ember/component/template-only';
import { hbs } from 'ember-cli-htmlbars';
import { setComponentTemplate } from '@ember/component';
export default setComponentTemplate(hbs`Hello World`, templateOnlyComponent());

After that transformation, the build proceeds as usual (so the inline hbs`` itself gets compiled down to wire format, for example). So you should be able to adapt your customized build to do that if you want to keep doing what you’ve been doing, just with co-located templates.

Switching gears to the big picture:

This whole strategy sounds dubious to me. Being able to override whole files is a great way to move fast and then end up with something very hard to maintain. I have also had to ship highly-customizable versions of an app off the same codebase, so I think I understand some of the constraints you’re under, and I would still suggest that it’s better to create a feature-based configuration that uses normal conditionals to branch into the different behaviors. It comes down to making the branches only as big as necessary and no bigger – when you’re overriding whole files, you can’t choose to make a tiny customization, every customization copies all the code and gives you yet another copy to maintain.

Using this strategy, it’s still possible to replace whole files when that is necessary, it’s just that you do it in the normal way, by refactoring into multiple different components and invoking them conditionally:

{{#if (feature-enabled "version-1") }}
  <Thing1 />
{{else if (feature-enabled "version-2")}}
  <Thing2 />
{{else}}
  <Thing />
{{/if}}

This might look “messier” but the messiness was already there. This is just more open about it and makes it easier for a future reader to understand what’s really going on.

There are other ways to slice it. For example, you can have a theme service that holds the themed components:

// app/components/example.js
import { inject } from '@ember/service';
import Component from '@glimmer/component';
export default class extends Component {
  @inject theme;
}
{{!-- app/components/example.hbs }}
<this.theme.button />
// app/services/theme.js
import Service from '@ember/service';

import Button1 from '../components/themes/1/button';
import Button2 from '../components/themes/2/button';

export default class extends Service {
  get button() {
    if (this.featureWhatever) {
      return Button1;
    } else {
      return Button2;
    }
  }
}

Regarding future-supported patterns: controlling component and helper registration at runtime via an instance initializer is definitely not going to make sense anymore with strict mode templates and template imports. Where we’re headed, components, helpers, and modifiers won’t go through the runtime resolver at all.

Also, you’re not going to be able to use embroider’s route splitting if you’re registering components at runtime. It needs to know which ones are used on every route, so they need to all come from their conventional locations. If you really want to swap out implementations under embroider, you can, but you’ll want to do it via @embroider/macros, and that would also mean having visible branches in your code itself, rather than magically replacing whole files.

3 Likes

First I would like to say: I don’t think I can describe how much I appreciate your feedback here @ef4. But suffice it to say, I really appreciate you taking the time to share your insights here.


Okay, my templates must be getting compiled to JS because they are not where ember normally expects to find them.

:+1: Very clear explanation on to use setComponentTemplate. I think my only remaining question – which might be irrelevant if we take your advice on going a different path – would be how to pass a template to setComponentTemplate when the template is a separate hbs file and not an inline string.

Some of the primary reasons we’ve gone down the override-whole-files path so far are:

  1. We need to ultimately support 14 versions. The idea of having any condition with 14 branches really turned us away from using conditional and more towards overriding files.
  2. It does seem simple and clean at the onset. When working on the applications, the rule just becomes ‘navigate to the correct versions directory and work away.’

As we have pushed the override-whole-files approach further, we have seen exactly what you describe e.g., if there’s one small customization in a file it often requires copying the entire file – even parts that are not version-specific :frowning: .

Your suggestions and examples on how to manage features and conditionals are helpful.

More than anything, the things you’ve clarified about the future of components is going to push us to rework how we’re handling versions. The thing that sparked taking another look at it in the first place was taking steps toward embroider compatibility, and that remains a primary goal.


After discussing your examples and thinking more about them, I think we might end up with a blend of what you’ve suggested, something like the following for components:

// app/service/version-manager.ts
export default class VersionManagerService extends Service {
  config = getOwner(this).lookup('main:config');
  version: Version = this.config.version;
}
// app/components/example/index.ts

import Icon1 from './versions/icon-1';
import Icon2 from './versions/icon-2';
import Icon3 from './versions/icon-3';

const IconVersions = {
  version1: Icon1,
  version2: Icon2,
  version3: Icon3,
}

export default class Example extends Component {
  @service versionManager: VersionManager;

  get icon() {
    return VersionIcons[this.versionManager.version];
  }
}
{{! app/component/example/index.hbs }}
<this.icon />

Another route we are looking at is using decorators to handle drying up the conditionals, for example:

import macro from 'macro-decorators';
import { getOwner } from '@ember/application';
import { dasherize } from '@ember/string';

export function columnsFor(fileType: string): PropertyDecorator {
  return macro({
    get() {
      const owner = getOwner(this);
      const versionManager = owner.lookup(`service:version-manager`);
      let columnDefs;

      // Try to find version-specific columns
      columnDefs = owner.factoryFor(`util:tables/column-definitions/styles/${versionManager.version}/${dasherize(fileType)}`)?.class;

      // If no version-specific columns are found, fallback to the defaults
      if (!columnDefs) {
        columnDefs = owner.factoryFor(`util:tables/column-definitions/${dasherize(fileType)}`)?.class;
      }

      return columnDefs.class?.columns;
    },
  });
}

and usage:

export default class SomeController extends Controller {
  @columnsFor('some-table') tableColumns: Array<ColumnDefinition>;
}

Thank you again @ef4 :pray:

1 Like