Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
255 lines (191 sloc) 8.55 KB

Injection Parameter Normalization

Summary

Normalize on passing the owner as the first parameter to the constructor for the following built in framework classes:

  • GlimmerComponent
  • EmberComponent
  • Service
  • Route
  • Controller
  • Helper

Along with the following Ember Data classes:

  • Model
  • Adapter
  • Serializer
  • Transform

Terminology

  • Explicit injections are injections which are defined in the class body using the inject APIs:

    import Component from '@ember/component';
    import { inject as service } from '@ember/service';
    
    export default Component.extend({
      store: service(),
    });

    The are explicit because they don't require any knowledge of the system to outside of the class itself to know they exist.

  • Implicit injections are injections which are defined using the container APIs directly, often in initializers:

    import Application from '@ember/application';
    
    Application.initializer({
      name: 'inject-session',
    
      initialize() {
        // Inject the session service onto all factories of the type 'controller'
        // with the name 'session'
        App.inject('controller', 'session', 'service:session');
      },
    });

    They are implicit because they require knowledge of the context of the class to know whether or not they exist, simply looking at the class body (without looking at method logic) will not hint at their existence. The canonical example here is the Ember Data store, which is implicitly injected into all routes and controllers.

Motivation

The introduction of native class syntax in Ember has recently exposed some of the inner-workings and expectations of Ember's Dependency Injection (DI) system. Specifically, it is now possible to write code that can run before dependencies are injected in some base classes, such as Services and Controllers. Currently, users must use the init hook in these classes if they wish to run setup code that accesses injections, but this is somewhat confusing since init has historically been taught as the same as the constructor in native classes.

Glimmer components made the decision to break from this pattern, and instead pass the DI Owner as the first parameter to the constructor. They then set it using setOwner in the base class, making explicit injections available during the constructor, and to class field initializers.

So far this has worked pretty well in practice:

  • Glimmer components have just 2 lifecycle hooks, which makes them simpler to understand and learn about.
  • We don't have to teach the differences between constructor and init, when to use one or the other, and debugging issues when the two have mixed usage throughout the class hierarchy
  • We don't have to worry about explaining the timings/lifecycle of the container and the way it constructs classes in order to explain why these are separate.

This RFC seeks to normalize this contract for all Ember base classes - that is, framework classes that are provided by Ember:

  • GlimmerComponent
  • EmberComponent
  • Service
  • Route
  • Controller
  • Helper

Along with framework clases provided by Ember Data:

  • Model
  • Adapter
  • Serializer
  • Transform

This RFC does not aim to provide a single contract for all classes registered on the container, in perpetuity. This would lock us into a tight coupling between the container and constructors for objects that are registered, and wouldn't provide much flexibility in the future.

Instead, we believe we should continue exploring APIs for generalizing the way DI is configured for a given base class. It could be done via custom managers, or via decoration, like the Injection Hook Normalization RFC. When these APIs are fully rationalized and accepted, we'll update Ember's base classes to use them to specify the owner injection like any other user class could.

Detailed design

This RFC has 2 major parts:

  1. The contract that we'll uphold for dependency injection in Ember base classes.
  2. The implementation of that contract for existing base classes (the tunnel).

Dependency Injection Contract

For all Ember base classes created by the container, such as GlimmerComponent, Service, Controller, etc. we will:

  1. Pass the owner as the first parameter when constructing the class.
  2. Set the owner with setOwner in the base class constructor.

This will make explicit injections available during the constructor method of the class, and for access by class field initializers.

This contract only applies to Ember base classes and framework objects, and classes that extend EmberObject. It does not apply to arbitrary classes that are created and registered in the container.

Implementation

The "tunnel" itself is fairly simple. As described in the Constructor Update RFC, this is how the create method on framework classes works currently:

class Service extends EmberObject {
  constructor() {
    super();
    // ..class setup things
  }

  static create(args) {
    let instance = new this();

    Object.assign(instance, args);
    instance.init();

    return instance;
  }
}

We would update this to the following:

class Service extends EmberObject {
  constructor(owner) {
    super();
    setOwner(this, owner);
    // ..class setup things
  }

  static create(args) {
    let owner = args ? getOwner(args) : undefined;
    let instance = new this(owner);

    Object.assign(instance, args);
    instance.init();

    return instance;
  }
}

Now, when any subclass's constructor code is run it will have the owner available, which in turn makes all explicit injections work (they use getOwner under the hood).

However, implicit injections will still only be available during init, because they are passed in and assigned as args. This RFC proposes that rather than attempting to fix implicit injections, we create development-mode assertions for them which throw if a user attempts to use them during the constructor, before they are assigned. This will give a helpful error message that instructs them to add the injection explicitly (ideally), or to use init.

Backwards Compatibility

This change is backwards compatible, so existing applications will not be affected. These changes will also be backported to at least:

  • lts-3.8
  • lts-3.4

via the ember-native-class-polyfill, which currently supports polyfilling to Ember@3.4. If possible, that range will be extended to the last v2 LTS versions.

How we teach this

This change would take some burden off of the guides for new Ember users, post-Octane, since it would simplify them. New documentation should only refer to constructor when talking about native class syntax, and should guide users toward using constructor over init.

For existing apps and upgrade documentation, the distinction needs to be made clear about the two types of classes that should still use init:

  • Classic Components
  • Utility Classes (e.g. user defined classes that extend EmberObject)

These two require init if users need access to component args or create args, respectively.

The main guides will recommend that users refactor these classes entirely rather than convert them to native classes. Classic components should become Glimmer components, and utility classes should be refactor away from extending EmberObject.

The @classic decorator will also provide a way to guide users toward the correct usage, based on whether they are in "classic" mode or "octane" mode. We will be able to provide linting and warnings/assertions to prevent users from accidentally using init when they should have used constructor, and vice-versa.

Drawbacks

  • More churn in the ecosystem, early adopters of classes already switched from constructor -> init, switching back would be painful.
  • Where to use init and where to use constructor may be a bit less clear after. This was already a concern with GlimmerComponent, but it may be more problematic if there are more exceptions.

Alternatives

  • Add an init hook to GlimmerComponent to unify it with the classic classes. This could be confusing to users of GlimmerComponent (why do injections work in GC but not any other class constructor? Why does GC have init and constructor?)
  • Keep using init for classic classes for the indefinite future, and teach around it.
You can’t perform that action at this time.