Skip to content

Update Inversify to version 8 - #561

Merged
gfontorbe merged 3 commits into
masterfrom
inversify8
Sep 3, 2026
Merged

Update Inversify to version 8#561
gfontorbe merged 3 commits into
masterfrom
inversify8

Conversation

@gfontorbe

Copy link
Copy Markdown
Contributor

InversifyJS 8 is a rewrite split across @inversifyjs/{common,container,core}; the inversify package is now a thin re-export barrel. This updates all Sprotty packages to ~8.2 and migrates the codebase.

Breaking changes for downstream code

Two of these fail silently — no compiler error, no DI error, just undefined dependencies at first use.

  1. Subclasses need @injectFromBase(). InversifyJS 8 no longer passes injection metadata down to subclasses. A subclass of a Sprotty class with injected members that lacks the decorator resolves with its inherited dependencies undefined and raises no error. If the base class declares constructor parameters that are not injected, they now need @unmanaged(), because @injectFromBase() eagerly validates the base class metadata.
  2. isInjectable removed from the public API (see "Design decisions" below).
  3. TYPES.Action is now bound in the container that registers commands rather than in a per-action child container, so it resolves to undefined while no command is being created.
  4. TYPES.IViewer is now bound in the main container with whenParentIs constraints rather than in child containers.

Changelog entries covering these, plus the InversifyJS-level changes that affect any application with a di.config.ts, are in this PR.

Mechanical migration

Change Sites
interfaces namespace → top-level type exports 10
ContainerModule callback: positional args → single options object 42
toProvidertoFactory (type argument moves to bind<T>, and is the provider type) 5
ctx.container.get(...)ctx.get(...) 14
ctx.container.isBound(x) ? get : undefinedctx.get(x, { optional: true }) 3
injectable()(Cls)decorate(injectable(), Cls) (now returns void) 3
@injectFromBase() added 26

Two traps worth knowing if you review the diff:

  • interfaces.Rebind maps to RebindSync, not Rebind — the latter is asynchronous in 8.
  • toFactory's parameter type is T extends Factory<...> ? ... : never. If bind<T> names the provided type rather than the provider function type, T isn't factory-shaped and every callback is rejected as not assignable to type 'never'.

The explicit import 'reflect-metadata' statements were removed: @inversifyjs/container side-effect-imports reflect-metadata/lite itself.

Design decisions worth reviewing

Sprotty used child containers as a contextual-injection mechanism in two places. InversifyJS 8 removed the tools for that — createChild(), the mutable Container.parent, and ResolutionContext.container — and there is no path from a module or a resolution back to the owning container. Each needed a different answer.

Viewer bindings (base/di.config.ts) previously built a child container per viewer so two ViewerCache instances could get different IViewer delegates. Replaced with constrained bindings:

bind<IViewer>(TYPES.IViewer).toDynamicValue(ctx => ctx.get(ModelViewer)).whenParentIs(TYPES.ModelViewer);
bind<IViewer>(TYPES.IViewer).toDynamicValue(ctx => ctx.get(PopupModelViewer)).whenParentIs(TYPES.PopupModelViewer);
bind(TYPES.ModelViewer).to(ViewerCache).inSingletonScope();
bind(TYPES.PopupModelViewer).to(ViewerCache).inSingletonScope();

whenParentIs matches on the service identifier that requested the injection, which is what distinguishes the two instances of the same class. Chosen over converting ViewerCache's @inject properties to constructor parameters plus toResolvedValue, since that would have changed the public shape of an exported class. Both were verified to work; this one leaves viewer-cache.ts untouched.

Command registration (command-registration.ts) built a child container per dispatched action to bind TYPES.Action. The action only exists per dispatch, so no binding-time construct expresses it. Replaced with a holder bound once per container:

factory: (action: Action) => {
    const holder = ctx.get<ActionHolder>(ACTION_HOLDER);
    const previous = holder.action;
    holder.action = action;
    try {
        return ctx.get<ICommand>(constr);
    } finally {
        holder.action = previous;
    }
}

It is mutable state, contained to one file behind one unexported symbol, and live only for the duration of a synchronous get. It is per-container rather than global, because the holder is itself a binding. Save-and-restore rather than clear-to-undefined because property injection runs after the constructor body, so a constructor that dispatched an action would otherwise blank the outer command's fields. Command scope is unchanged: still a fresh instance per dispatch.

The alternative was binding each container into itself so a real child container could be created. That needs container.bind(TYPES.Container).toConstantValue(container) at 36 call sites, cannot be done from inside a ContainerModule, and leaves downstream consumers with a runtime failure until they add a self-binding they have never heard of. The holder also avoids allocating a container per dispatched action.

isInjectable removed. It read Reflect.getMetadata('inversify:paramtypes', ...), a key InversifyJS 8 never writes, so it returned false for every class and every configure* utility threw on module load. Reading InversifyJS 8's equivalent key means depending on an internal of @inversifyjs/core, which exports neither the constant nor a public injectability check. Instead the configure* utilities now let the binding fail and translate the error:

try {
    context.bind(constr).toSelf();
} catch (error) {
    throw new Error(`${role} must be decorated with @injectable(): ${constr.name}. …`, { cause: error });
}

Public API only, no error discrimination needed, and the original error is preserved as cause. Two consequences:

  • Narrower than before: InversifyJS only rejects a missing decorator when the constructor takes arguments. A class whose dependencies are all injected into properties binds without complaint. There is no public API to detect that, and it is recorded as an explicit test in utils/inversify.spec.ts so it isn't mistaken for a bug later.
  • More accurate in the other direction: the old guard rejected undecorated classes that InversifyJS 8 handles fine. graph/views.spec.tsx was failing on exactly that — a CircleNodeView fixture with no decorator and no injected members. It passes now.

The message names @injectFromBase as well as @injectable, since the former is the failure most people migrating will actually hit.

Verification

The examples present in the repo were hand tested to check that everything still works as expected

@gfontorbe
gfontorbe requested a review from spoenemann August 26, 2026 13:14
@gfontorbe gfontorbe added this to the v2.0.0 milestone Aug 27, 2026

@spoenemann spoenemann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much! A huge change with lots of consequences, but we need to stay up-to-date.

* InversifyJS 8 offers no way to reach the container from a resolution, so a contextual binding
* cannot be provided by a per-action child container any more.
*/
const ACTION_HOLDER = Symbol('ActionHolder');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be Symbol.for?

@gfontorbe
gfontorbe merged commit 04861f8 into master Sep 3, 2026
2 checks passed
@gfontorbe
gfontorbe deleted the inversify8 branch September 3, 2026 07:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants