@joist/di 3.0.1 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joist/di",
3
- "version": "3.0.1",
3
+ "version": "3.0.4",
4
4
  "type": "module",
5
5
  "main": "./target/lib.js",
6
6
  "module": "./target/lib.js",
@@ -13,6 +13,7 @@
13
13
  }
14
14
  },
15
15
  "files": [
16
+ "src",
16
17
  "target"
17
18
  ],
18
19
  "sideEffects": false,
@@ -0,0 +1,30 @@
1
+ import { expect, fixture, html } from '@open-wc/testing';
2
+
3
+ import { Injector } from './injector.js';
4
+ import { environment, clearEnvironment } from './environment.js';
5
+ import { injectable } from './injectable.js';
6
+ import { inject } from './inject.js';
7
+
8
+ describe('environment', () => {
9
+ afterEach(clearEnvironment);
10
+
11
+ it('should create a global Injector instance', () => {
12
+ expect(environment()).to.be.instanceOf(Injector);
13
+ });
14
+
15
+ it('should use the root injector when creating services', async () => {
16
+ @injectable
17
+ class MyService { }
18
+
19
+ @injectable
20
+ class MyElement extends HTMLElement {
21
+ my = inject(MyService);
22
+ }
23
+
24
+ customElements.define('env-1', MyElement);
25
+
26
+ const el = await fixture<MyElement>(html`<env-1></env-1>`);
27
+
28
+ expect(el.my()).to.equal(environment().get(MyService));
29
+ });
30
+ });
@@ -0,0 +1,17 @@
1
+ import { Injector } from './injector.js';
2
+ import { Provider } from './provider.js';
3
+
4
+ const rootInjector = new Injector();
5
+
6
+ export function environment(): Injector {
7
+ return rootInjector;
8
+ }
9
+
10
+ export function defineEnvironment(providers: Provider<any>[]) {
11
+ environment().providers = providers;
12
+ }
13
+
14
+ export function clearEnvironment(): void {
15
+ rootInjector.providers = [];
16
+ rootInjector.clear();
17
+ }
@@ -0,0 +1,71 @@
1
+ import { expect } from '@open-wc/testing';
2
+
3
+ import { inject } from './inject.js';
4
+ import { injectable } from './injectable.js';
5
+ import { Injector } from './injector.js';
6
+
7
+ describe('inject', () => {
8
+ it('should work', () => {
9
+ class HelloService { }
10
+
11
+ @injectable
12
+ class HelloWorld extends HTMLElement {
13
+ hello = inject(HelloService);
14
+ }
15
+
16
+ customElements.define('inject-1', HelloWorld);
17
+
18
+ expect(new HelloWorld().hello()).to.be.instanceOf(HelloService);
19
+ });
20
+
21
+ it('should throw error if called in constructor', () => {
22
+ class FooService {
23
+ value = '1';
24
+ }
25
+
26
+ @injectable
27
+ class BarService {
28
+ foo = inject(FooService);
29
+
30
+ constructor() {
31
+ this.foo();
32
+ }
33
+ }
34
+
35
+ const parent = new Injector();
36
+
37
+ try {
38
+ parent.get(BarService);
39
+
40
+ throw new Error('Should not succeed');
41
+ } catch (err) {
42
+ const error = err as Error;
43
+
44
+ expect(error.message).to.equal(
45
+ `BarService is either not injectable or a service is being called in the constructor. \n Either add the @injectable to your class or use the onInject callback method.`
46
+ );
47
+ }
48
+ });
49
+
50
+ it('should use the calling injector as parent', () => {
51
+ class FooService {
52
+ value = '1';
53
+ }
54
+
55
+ @injectable
56
+ class BarService {
57
+ foo = inject(FooService);
58
+ }
59
+
60
+ const parent = new Injector([
61
+ {
62
+ provide: FooService,
63
+ use: class extends FooService {
64
+ value = '100';
65
+ },
66
+ },
67
+ ]);
68
+
69
+ expect(parent.get(BarService).foo().value).to.equal('100');
70
+ });
71
+ });
@@ -0,0 +1,20 @@
1
+ import { ProviderToken } from './provider.js';
2
+ import { Injectable } from './injector.js';
3
+
4
+ export type Injected<T> = () => T;
5
+
6
+ export function inject<This extends Injectable, T extends object>(
7
+ token: ProviderToken<T>
8
+ ): Injected<T> {
9
+ return function (this: This) {
10
+ if (this.injector$$ === undefined) {
11
+ const name = Object.getPrototypeOf(this.constructor).name;
12
+
13
+ throw new Error(
14
+ `${name} is either not injectable or a service is being called in the constructor. \n Either add the @injectable to your class or use the onInject callback method.`
15
+ );
16
+ }
17
+
18
+ return this.injector$$.get(token);
19
+ };
20
+ }
@@ -0,0 +1,144 @@
1
+ import { expect, fixture, html } from '@open-wc/testing';
2
+
3
+ import { injectable } from './injectable.js';
4
+ import { inject } from './inject.js';
5
+ import { Injector } from './injector.js';
6
+
7
+ describe('@injectable()', () => {
8
+ it('should allow a custom element to be injected with deps', () => {
9
+ class Foo {}
10
+ class Bar {}
11
+
12
+ @injectable
13
+ class MyElement extends HTMLElement {
14
+ foo = inject(Foo);
15
+ bar = inject(Bar);
16
+ }
17
+
18
+ customElements.define('injectable-1', MyElement);
19
+
20
+ const el = document.createElement('injectable-1') as MyElement;
21
+
22
+ expect(el.foo()).to.be.instanceOf(Foo);
23
+ });
24
+
25
+ it('should locally override a provider', () => {
26
+ class Foo {}
27
+
28
+ class Bar extends Foo {}
29
+
30
+ const MyElement = injectable(
31
+ class {
32
+ static providers = [{ provide: Foo, use: Bar }];
33
+
34
+ foo = inject(Foo);
35
+ }
36
+ );
37
+
38
+ const el = new MyElement();
39
+
40
+ expect(el.foo()).to.be.instanceOf(Bar);
41
+ });
42
+
43
+ it('should call the onInject lifecycle hook', () => {
44
+ class A {}
45
+
46
+ @injectable
47
+ class B {
48
+ a = inject(A);
49
+
50
+ onInject() {
51
+ expect(this.a()).to.be.instanceOf(A);
52
+ }
53
+ }
54
+
55
+ new Injector().get(B);
56
+ });
57
+
58
+ it('should handle parent HTML Injectors', async () => {
59
+ @injectable
60
+ class A {}
61
+
62
+ const B = injectable(
63
+ class {
64
+ a = inject(A);
65
+ }
66
+ );
67
+
68
+ class AltA implements A {}
69
+
70
+ @injectable
71
+ class Parent extends HTMLElement {
72
+ static providers = [
73
+ { provide: B, use: B },
74
+ { provide: A, use: AltA },
75
+ ];
76
+ }
77
+
78
+ @injectable
79
+ class Child extends HTMLElement {
80
+ b = inject(B);
81
+ }
82
+
83
+ customElements.define('injectable-parent-1', Parent);
84
+ customElements.define('injectable-child-1', Child);
85
+
86
+ const el = await fixture(html`
87
+ <injectable-parent-1>
88
+ <injectable-child-1></injectable-child-1>
89
+ </injectable-parent-1>
90
+ `);
91
+
92
+ const child = el.querySelector<Child>('injectable-child-1')!;
93
+
94
+ expect(child.b().a()).to.be.instanceOf(AltA);
95
+ });
96
+
97
+ it('should handle changing contexts', async () => {
98
+ class A {}
99
+ class AltA implements A {}
100
+
101
+ @injectable
102
+ class Ctx1 extends HTMLElement {
103
+ static providers = [{ provide: A, use: A }];
104
+ }
105
+
106
+ @injectable
107
+ class Ctx2 extends HTMLElement {
108
+ static providers = [{ provide: A, use: AltA }];
109
+ }
110
+
111
+ @injectable
112
+ class Child extends HTMLElement {
113
+ a = inject(A);
114
+ }
115
+
116
+ customElements.define('ctx-1', Ctx1);
117
+ customElements.define('ctx-2', Ctx2);
118
+ customElements.define('ctx-child', Child);
119
+
120
+ const el = await fixture(html`
121
+ <div>
122
+ <ctx-1>
123
+ <ctx-child></ctx-child>
124
+ </ctx-1>
125
+
126
+ <ctx-2></ctx-2>
127
+ </div>
128
+ `);
129
+
130
+ const ctx2 = el.querySelector('ctx-2')!;
131
+
132
+ let child = el.querySelector<Child>('ctx-child')!;
133
+
134
+ expect(child.a()).to.be.instanceOf(A);
135
+
136
+ child.remove();
137
+
138
+ ctx2.append(child);
139
+
140
+ child = el.querySelector<Child>('ctx-child')!;
141
+
142
+ expect(child.a()).to.be.instanceOf(AltA);
143
+ });
144
+ });
@@ -0,0 +1,69 @@
1
+ import { ProviderToken } from './provider.js';
2
+ import { Injectable, Injector } from './injector.js';
3
+ import { environment } from './environment.js';
4
+
5
+ export function injectable<T extends ProviderToken<any>>(Base: T, _?: unknown) {
6
+ return class InjectableNode extends Base implements Injectable {
7
+ injector$$ = new Injector(Base.providers);
8
+
9
+ constructor(..._: any[]) {
10
+ super();
11
+
12
+ try {
13
+ if (this instanceof HTMLElement) {
14
+ this.addEventListener('finddiroot', (e) => {
15
+ const parentInjector = findInjectorRoot(e);
16
+
17
+ if (parentInjector !== null) {
18
+ this.injector$$.setParent(parentInjector);
19
+ } else {
20
+ this.injector$$.setParent(environment());
21
+ }
22
+ });
23
+ }
24
+ } catch {}
25
+ }
26
+
27
+ onInject() {
28
+ if (super.onInject) {
29
+ super.onInject();
30
+ }
31
+ }
32
+
33
+ connectedCallback() {
34
+ try {
35
+ if (this instanceof HTMLElement) {
36
+ this.dispatchEvent(new Event('finddiroot'));
37
+
38
+ if (super.connectedCallback) {
39
+ super.connectedCallback();
40
+ }
41
+ }
42
+ } catch {}
43
+ }
44
+
45
+ disconnectedCallback() {
46
+ this.injector$$.setParent(undefined);
47
+
48
+ if (super.disconnectedCallback) {
49
+ super.disconnectedCallback();
50
+ }
51
+ }
52
+ };
53
+ }
54
+
55
+ function findInjectorRoot(e: Event): Injector | null {
56
+ const path = e.composedPath();
57
+
58
+ // find firt parent
59
+ // skips the first item which is the target
60
+ for (let i = 1; i < path.length; i++) {
61
+ const part = path[i];
62
+
63
+ if ('injector$$' in part && part.injector$$ instanceof Injector) {
64
+ return part.injector$$;
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
@@ -0,0 +1,86 @@
1
+ import { expect } from '@open-wc/testing';
2
+
3
+ import { Injector } from './injector.js';
4
+ import { inject } from './inject.js';
5
+ import { injectable } from './injectable.js';
6
+
7
+ describe('Injector', () => {
8
+ it('should create a new instance of a single provider', () => {
9
+ class A { }
10
+
11
+ const app = new Injector();
12
+
13
+ expect(app.get(A)).to.be.instanceOf(A);
14
+ expect(app.get(A)).to.equal(app.get(A));
15
+ });
16
+
17
+ it('should inject providers in the correct order', () => {
18
+ class A { }
19
+ class B { }
20
+
21
+ @injectable
22
+ class MyService {
23
+ a = inject(A);
24
+ b = inject(B);
25
+ }
26
+
27
+ const app = new Injector();
28
+ const instance = app.get(MyService);
29
+
30
+ expect(instance.a()).to.be.instanceOf(A);
31
+ expect(instance.b()).to.be.instanceOf(B);
32
+ });
33
+
34
+ it('should create a new instance of a provider that has a full dep tree', () => {
35
+ class A { }
36
+
37
+ @injectable
38
+ class B {
39
+ a = inject(A);
40
+ }
41
+
42
+ @injectable
43
+ class C {
44
+ b = inject(B);
45
+ }
46
+
47
+ @injectable
48
+ class D {
49
+ c = inject(C);
50
+ }
51
+
52
+ @injectable
53
+ class E {
54
+ d = inject(D);
55
+ }
56
+
57
+ const app = new Injector();
58
+ const instance = app.get(E);
59
+
60
+ expect(instance.d().c().b().a()).to.be.instanceOf(A);
61
+ });
62
+
63
+ it('should override a provider if explicitly instructed', () => {
64
+ class A { }
65
+
66
+ @injectable
67
+ class B {
68
+ a = inject(A);
69
+ }
70
+
71
+ class AltA extends A { }
72
+ const app = new Injector([{ provide: A, use: AltA }]);
73
+
74
+ expect(app.get(B).a()).to.be.instanceOf(AltA);
75
+ });
76
+
77
+ it('should return an existing instance from a parent injector', () => {
78
+ class A { }
79
+
80
+ const parent = new Injector();
81
+
82
+ const app = new Injector([], parent);
83
+
84
+ expect(parent.get(A)).to.equal(app.get(A));
85
+ });
86
+ });
@@ -0,0 +1,105 @@
1
+ import { ProviderToken, Provider } from './provider.js';
2
+
3
+ // defines available properties that will be on a class instance that can use the inject function
4
+ export type Injectable = object & {
5
+ injector$$?: Injector;
6
+ onInject?(): void;
7
+ };
8
+
9
+ /**
10
+ * Injectors create and store instances of services.
11
+ * A service is any constructable class.
12
+ * When calling Injector.get, the injector will resolve as following.
13
+ *
14
+ * 1. Do I have a cached instance locally?
15
+ * 2. Do I have a local provider definition for the token?
16
+ * 3. Do I have a parent? Check parent for 1 and 2
17
+ * 5. All clear, go ahead and construct and cache the requested service
18
+ *
19
+ * RootInjector |--> InjectorA |--> InjectorB
20
+ * |--> InjectorC
21
+ * |--> InjectorD |--> InjectorE
22
+ *
23
+ * in the above tree, if InjectorE requests a service, it will navigate up to the RootInjector and cache.
24
+ * If Inject B then requests the same token, it will recieve the same cached instance from RootInjector.
25
+ */
26
+ export class Injector {
27
+ // ke track of isntances. One Token can have one instance
28
+ #instances = new WeakMap<ProviderToken<any>, any>();
29
+
30
+ #parent: Injector | undefined = undefined;
31
+
32
+ constructor(public providers: Provider<any>[] = [], parent?: Injector) {
33
+ this.setParent(parent);
34
+ }
35
+
36
+ // resolves and retuns and instance of the requested service
37
+ get<T extends Injectable>(token: ProviderToken<T>): T {
38
+ // check for a local instance
39
+ if (this.#instances.has(token)) {
40
+ return this.#instances.get(token)!;
41
+ }
42
+
43
+ const provider = this.#findProvider(token);
44
+
45
+ // check for a provider definition
46
+ if (provider) {
47
+ return this.#createAndCache<T>(provider.use);
48
+ }
49
+
50
+ // check for a parent and attempt to get there
51
+ if (this.#parent) {
52
+ return this.#parent.get(token);
53
+ }
54
+
55
+ return this.#createAndCache(token);
56
+ }
57
+
58
+ setParent(parent: Injector | undefined) {
59
+ this.#parent = parent;
60
+ }
61
+
62
+ clear() {
63
+ this.#instances = new WeakMap();
64
+ }
65
+
66
+ #createAndCache<T extends Injectable>(token: ProviderToken<T>): T {
67
+ const instance = new token();
68
+
69
+ this.#instances.set(token, instance);
70
+
71
+ if (instance.injector$$ instanceof Injector) {
72
+ /**
73
+ * set the this injector instance as a parent.
74
+ * this means that each calling injector will be the parent of what it creates.
75
+ * this allows the created service to navigate up it's chain to find a root
76
+ */
77
+ instance.injector$$.setParent(this);
78
+
79
+ /**
80
+ * the on inject lifecycle hook should be called after the parent is defined.
81
+ * this ensures that services are initialized when the chain is settled
82
+ * this is required since the parent is set after the instance is constructed
83
+ */
84
+ if (instance.onInject) {
85
+ instance.onInject();
86
+ }
87
+ }
88
+
89
+ return instance;
90
+ }
91
+
92
+ #findProvider(token: ProviderToken<any>): Provider<any> | undefined {
93
+ if (!this.providers) {
94
+ return undefined;
95
+ }
96
+
97
+ for (let i = 0; i < this.providers.length; i++) {
98
+ if (this.providers[i].provide === token) {
99
+ return this.providers[i];
100
+ }
101
+ }
102
+
103
+ return undefined;
104
+ }
105
+ }
@@ -0,0 +1,10 @@
1
+ export type ProviderToken<T> = {
2
+ providers?: Provider<any>[];
3
+
4
+ new(...args: any[]): T;
5
+ };
6
+
7
+ export interface Provider<T> {
8
+ provide: ProviderToken<T>;
9
+ use: ProviderToken<T>;
10
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { Injector, Injectable } from './lib/injector.js';
2
+ export { Provider, ProviderToken } from './lib/provider.js';
3
+ export { injectable } from './lib/injectable.js';
4
+ export { inject, Injected } from './lib/inject.js';
5
+ export { defineEnvironment, clearEnvironment } from './lib/environment.js';
@@ -1,3 +1,5 @@
1
1
  import { Injector } from './injector.js';
2
+ import { Provider } from './provider.js';
2
3
  export declare function environment(): Injector;
4
+ export declare function defineEnvironment(providers: Provider<any>[]): void;
3
5
  export declare function clearEnvironment(): void;
@@ -3,6 +3,9 @@ const rootInjector = new Injector();
3
3
  export function environment() {
4
4
  return rootInjector;
5
5
  }
6
+ export function defineEnvironment(providers) {
7
+ environment().providers = providers;
8
+ }
6
9
  export function clearEnvironment() {
7
10
  rootInjector.providers = [];
8
11
  rootInjector.clear();
@@ -1 +1 @@
1
- {"version":3,"file":"environment.js","sourceRoot":"","sources":["../../src/lib/environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAEzC,MAAM,YAAY,GAAG,IAAI,QAAQ,EAAE,CAAC;AAEpC,MAAM,UAAU,WAAW;IACzB,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,YAAY,CAAC,SAAS,GAAG,EAAE,CAAC;IAC5B,YAAY,CAAC,KAAK,EAAE,CAAC;AACvB,CAAC"}
1
+ {"version":3,"file":"environment.js","sourceRoot":"","sources":["../../src/lib/environment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGzC,MAAM,YAAY,GAAG,IAAI,QAAQ,EAAE,CAAC;AAEpC,MAAM,UAAU,WAAW;IACzB,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,SAA0B;IAC1D,WAAW,EAAE,CAAC,SAAS,GAAG,SAAS,CAAC;AACtC,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,YAAY,CAAC,SAAS,GAAG,EAAE,CAAC;IAC5B,YAAY,CAAC,KAAK,EAAE,CAAC;AACvB,CAAC"}
package/target/lib.d.ts CHANGED
@@ -2,3 +2,4 @@ export { Injector, Injectable } from './lib/injector.js';
2
2
  export { Provider, ProviderToken } from './lib/provider.js';
3
3
  export { injectable } from './lib/injectable.js';
4
4
  export { inject, Injected } from './lib/inject.js';
5
+ export { defineEnvironment, clearEnvironment } from './lib/environment.js';
package/target/lib.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Injector } from './lib/injector.js';
2
2
  export { injectable } from './lib/injectable.js';
3
3
  export { inject } from './lib/inject.js';
4
+ export { defineEnvironment, clearEnvironment } from './lib/environment.js';
4
5
  //# sourceMappingURL=lib.js.map
package/target/lib.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"lib.js","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAc,MAAM,mBAAmB,CAAC;AAEzD,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAY,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"lib.js","sourceRoot":"","sources":["../src/lib.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAc,MAAM,mBAAmB,CAAC;AAEzD,OAAO,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,MAAM,EAAY,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC"}