From ed93378725ef13200e5de154768ca4b46602cb89 Mon Sep 17 00:00:00 2001 From: Steven Hobson-Campbell Date: Sat, 17 Oct 2020 21:07:45 -0700 Subject: [PATCH 1/2] [feat] Add support for cleaning up disposable instances --- README.md | 9 ++++ src/__tests__/disposable.test.ts | 27 +++++++++++ src/__tests__/global-container.test.ts | 62 +++++++++++++++++++++++++ src/dependency-container.ts | 64 ++++++++++++++++++++++---- src/index.ts | 1 + src/types/dependency-container.ts | 9 +++- src/types/disposable.ts | 16 +++++++ src/types/index.ts | 1 + 8 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/disposable.test.ts create mode 100644 src/types/disposable.ts diff --git a/README.md b/README.md index e84420e..96211c9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ constructor injection. - [Circular dependencies](#circular-dependencies) - [The `delay` helper function](#the-delay-helper-function) - [Interfaces and circular dependencies](#interfaces-and-circular-dependencies) +- [Disposable instances](#disposable-instances) - [Full examples](#full-examples) - [Example without interfaces](#example-without-interfaces) - [Example with interfaces](#example-with-interfaces) @@ -680,6 +681,14 @@ export class Bar implements IBar { } ``` +# Disposable instances +All instances created by the container that implement the [`Disposable`](./src/types/disposable.ts) +interface will automatically be disposed of when the container is disposed. + +```typescript +container.dispose(); +``` + # Full examples ## Example without interfaces diff --git a/src/__tests__/disposable.test.ts b/src/__tests__/disposable.test.ts new file mode 100644 index 0000000..efa838f --- /dev/null +++ b/src/__tests__/disposable.test.ts @@ -0,0 +1,27 @@ +import Disposable, {isDisposable} from "../types/disposable"; + +describe("Disposable", () => { + describe("isDisposable", () => { + it("returns false for non-disposable object", () => { + const nonDisposable = {}; + + expect(isDisposable(nonDisposable)).toBeFalsy(); + }); + + it("returns false when dispose method takes too many args", () => { + const specialDisposable = { + dispose(_: any) {} + }; + + expect(isDisposable(specialDisposable)).toBeFalsy(); + }); + + it("returns true for disposable object", () => { + const disposable: Disposable = { + dispose() {} + }; + + expect(isDisposable(disposable)).toBeTruthy(); + }); + }); +}); diff --git a/src/__tests__/global-container.test.ts b/src/__tests__/global-container.test.ts index bda0ff2..6df7f67 100644 --- a/src/__tests__/global-container.test.ts +++ b/src/__tests__/global-container.test.ts @@ -11,6 +11,7 @@ import {instance as globalContainer} from "../dependency-container"; import injectAll from "../decorators/inject-all"; import Lifecycle from "../types/lifecycle"; import {ValueProvider} from "../providers"; +import Disposable from "../types/disposable"; interface IBar { value: string; @@ -820,3 +821,64 @@ test("predicateAwareClassFactory returns new instances each call with caching of expect(factory(globalContainer)).not.toBe(factory(globalContainer)); }); + +describe("dispose", () => { + class Foo implements Disposable { + disposed = false; + dispose(): void { + this.disposed = true; + } + } + class Bar implements Disposable { + disposed = false; + dispose(): void { + this.disposed = true; + } + } + + it("renders the container useless", () => { + const container = globalContainer.createChildContainer(); + container.dispose(); + + expect(() => container.register("Bar", {useClass: Bar})).toThrow( + /disposed/ + ); + expect(() => container.reset()).toThrow(/disposed/); + expect(() => container.resolve("indisposed")).toThrow(/disposed/); + }); + + it("disposes all child disposables", () => { + const container = globalContainer.createChildContainer(); + + const foo = container.resolve(Foo); + const bar = container.resolve(Bar); + + container.dispose(); + + expect(foo.disposed).toBeTruthy(); + expect(bar.disposed).toBeTruthy(); + }); + + it("disposes all instances of the same type", () => { + const container = globalContainer.createChildContainer(); + + const foo1 = container.resolve(Foo); + const foo2 = container.resolve(Foo); + + container.dispose(); + + expect(foo1.disposed).toBeTruthy(); + expect(foo2.disposed).toBeTruthy(); + }); + + it("doesn't dispose of instances created external to the container", () => { + const foo = new Foo(); + const container = globalContainer.createChildContainer(); + + container.registerInstance(Foo, foo); + container.resolve(Foo); + container.dispose(); + + expect(foo.disposed).toBeFalsy(); + }); +}); diff --git a/src/dependency-container.ts b/src/dependency-container.ts index ed29e54..8751a24 100644 --- a/src/dependency-container.ts +++ b/src/dependency-container.ts @@ -28,6 +28,7 @@ import Lifecycle from "./types/lifecycle"; import ResolutionContext from "./resolution-context"; import {formatErrorCtor} from "./error-helpers"; import {DelayedConstructor} from "./lazy-helpers"; +import Disposable, {isDisposable} from "./types/disposable"; import InterceptorOptions from "./types/interceptor-options"; import Interceptors from "./interceptors"; @@ -45,6 +46,8 @@ export const typeInfo = new Map, ParamInfo[]>(); class InternalDependencyContainer implements DependencyContainer { private _registry = new Registry(); private interceptors = new Interceptors(); + private disposed = false; + private disposables = new Set(); public constructor(private parent?: InternalDependencyContainer) {} @@ -81,6 +84,8 @@ class InternalDependencyContainer implements DependencyContainer { providerOrConstructor: Provider | constructor, options: RegistrationOptions = {lifecycle: Lifecycle.Transient} ): InternalDependencyContainer { + this.ensureNotDisposed(); + let provider: Provider; if (!isProvider(providerOrConstructor)) { @@ -139,6 +144,8 @@ class InternalDependencyContainer implements DependencyContainer { from: InjectionToken, to: InjectionToken ): InternalDependencyContainer { + this.ensureNotDisposed(); + if (isNormalToken(to)) { return this.register(from, { useToken: to @@ -154,6 +161,8 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken, instance: T ): InternalDependencyContainer { + this.ensureNotDisposed(); + return this.register(token, { useValue: instance }); @@ -171,6 +180,8 @@ class InternalDependencyContainer implements DependencyContainer { from: InjectionToken, to?: InjectionToken ): InternalDependencyContainer { + this.ensureNotDisposed(); + if (isNormalToken(from)) { if (isNormalToken(to)) { return this.register( @@ -213,6 +224,8 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken, context: ResolutionContext = new ResolutionContext() ): T { + this.ensureNotDisposed(); + const registration = this.getRegistration(token); if (!registration && isNormalToken(token)) { @@ -282,6 +295,8 @@ class InternalDependencyContainer implements DependencyContainer { registration: Registration, context: ResolutionContext ): T { + this.ensureNotDisposed(); + // If we have already resolved this scoped dependency, return it if ( registration.options.lifecycle === Lifecycle.ResolutionScoped && @@ -334,6 +349,8 @@ class InternalDependencyContainer implements DependencyContainer { token: InjectionToken, context: ResolutionContext = new ResolutionContext() ): T[] { + this.ensureNotDisposed(); + const registrations = this.getAllRegistrations(token); if (!registrations && isNormalToken(token)) { @@ -360,6 +377,8 @@ class InternalDependencyContainer implements DependencyContainer { } public isRegistered(token: InjectionToken, recursive = false): boolean { + this.ensureNotDisposed(); + return ( this._registry.has(token) || (recursive && @@ -369,12 +388,15 @@ class InternalDependencyContainer implements DependencyContainer { } public reset(): void { + this.ensureNotDisposed(); this._registry.clear(); this.interceptors.preResolution.clear(); this.interceptors.postResolution.clear(); } public clearInstances(): void { + this.ensureNotDisposed(); + for (const [token, registrations] of this._registry.entries()) { this._registry.setAll( token, @@ -391,6 +413,8 @@ class InternalDependencyContainer implements DependencyContainer { } public createChildContainer(): DependencyContainer { + this.ensureNotDisposed(); + const childContainer = new InternalDependencyContainer(this); for (const [token, registrations] of this._registry.entries()) { @@ -443,6 +467,11 @@ class InternalDependencyContainer implements DependencyContainer { }); } + public dispose(): void { + this.disposed = true; + this.disposables.forEach(disposable => disposable.dispose()); + } + private getRegistration(token: InjectionToken): Registration | null { if (this.isRegistered(token)) { return this._registry.get(token)!; @@ -478,18 +507,27 @@ class InternalDependencyContainer implements DependencyContainer { this.resolve(target, context) ); } - const paramInfo = typeInfo.get(ctor); - if (!paramInfo || paramInfo.length === 0) { - if (ctor.length === 0) { - return new ctor(); - } else { - throw new Error(`TypeInfo not known for "${ctor.name}"`); + + const instance: T = (() => { + const paramInfo = typeInfo.get(ctor); + if (!paramInfo || paramInfo.length === 0) { + if (ctor.length === 0) { + return new ctor(); + } else { + throw new Error(`TypeInfo not known for "${ctor.name}"`); + } } - } - const params = paramInfo.map(this.resolveParams(context, ctor)); + const params = paramInfo.map(this.resolveParams(context, ctor)); + + return new ctor(...params); + })(); + + if (isDisposable(instance)) { + this.disposables.add(instance); + } - return new ctor(...params); + return instance; } private resolveParams(context: ResolutionContext, ctor: constructor) { @@ -523,6 +561,14 @@ class InternalDependencyContainer implements DependencyContainer { } }; } + + private ensureNotDisposed(): void { + if (this.disposed) { + throw new Error( + "This container has been disposed, you cannot interact with a disposed container" + ); + } + } } export const instance: DependencyContainer = new InternalDependencyContainer(); diff --git a/src/index.ts b/src/index.ts index 90f4514..1699053 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ if (typeof Reflect === "undefined" || !Reflect.getMetadata) { export { DependencyContainer, + Disposable, Lifecycle, RegistrationOptions, Frequency diff --git a/src/types/dependency-container.ts b/src/types/dependency-container.ts index c9d6653..455c541 100644 --- a/src/types/dependency-container.ts +++ b/src/types/dependency-container.ts @@ -5,6 +5,7 @@ import ValueProvider from "../providers/value-provider"; import ClassProvider from "../providers/class-provider"; import constructor from "./constructor"; import RegistrationOptions from "./registration-options"; +import Disposable from "./disposable"; import InterceptorOptions from "./interceptor-options"; export type ResolutionType = "Single" | "All"; @@ -30,7 +31,7 @@ export interface PostResolutionInterceptorCallback { ): void; } -export default interface DependencyContainer { +export default interface DependencyContainer extends Disposable { register( token: InjectionToken, provider: ValueProvider @@ -120,4 +121,10 @@ export default interface DependencyContainer { callback: PostResolutionInterceptorCallback, options?: InterceptorOptions ): void; + + /** + * Calls `.dispose()` on all disposable instances created by the container. + * After calling this, the container may no longer be used. + */ + dispose(): void; } diff --git a/src/types/disposable.ts b/src/types/disposable.ts new file mode 100644 index 0000000..374052d --- /dev/null +++ b/src/types/disposable.ts @@ -0,0 +1,16 @@ +export default interface Disposable { + dispose(): void; +} + +export function isDisposable(value: any): value is Disposable { + if (typeof value.dispose !== "function") return false; + + const disposeFun: Function = value.dispose; + + // `.dispose()` takes in no arguments + if (disposeFun.length > 0) { + return false; + } + + return true; +} diff --git a/src/types/index.ts b/src/types/index.ts index e4cb0ad..5fa6cd6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export {default as DependencyContainer} from "./dependency-container"; export {default as Dictionary} from "./dictionary"; export {default as RegistrationOptions} from "./registration-options"; export {default as Lifecycle} from "./lifecycle"; +export {default as Disposable} from "./disposable"; export {default as InterceptionOptions} from "./interceptor-options"; export {default as Frequency} from "./frequency"; export {default as Transform} from "./transform"; From 26b5d4c2526447b0326877d53a4436abe30af22d Mon Sep 17 00:00:00 2001 From: Mikkel Hoegh Date: Fri, 29 Oct 2021 22:15:44 +0200 Subject: [PATCH 2/2] [feat] Add support for asynchronous disposables --- README.md | 6 ++++++ src/__tests__/global-container.test.ts | 23 +++++++++++++++++++++++ src/dependency-container.ts | 14 ++++++++++++-- src/types/dependency-container.ts | 2 +- src/types/disposable.ts | 2 +- 5 files changed, 43 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 96211c9..00d95d2 100644 --- a/README.md +++ b/README.md @@ -689,6 +689,12 @@ interface will automatically be disposed of when the container is disposed. container.dispose(); ``` +or to await all asynchronous disposals: + +```typescript +await container.dispose(); +``` + # Full examples ## Example without interfaces diff --git a/src/__tests__/global-container.test.ts b/src/__tests__/global-container.test.ts index 6df7f67..5685d03 100644 --- a/src/__tests__/global-container.test.ts +++ b/src/__tests__/global-container.test.ts @@ -835,6 +835,17 @@ describe("dispose", () => { this.disposed = true; } } + class Baz implements Disposable { + disposed = false; + async dispose(): Promise { + return new Promise(resolve => { + process.nextTick(() => { + this.disposed = true; + resolve(); + }); + }); + } + } it("renders the container useless", () => { const container = globalContainer.createChildContainer(); @@ -859,6 +870,18 @@ describe("dispose", () => { expect(bar.disposed).toBeTruthy(); }); + it("disposes asynchronous disposables", async () => { + const container = globalContainer.createChildContainer(); + + const foo = container.resolve(Foo); + const baz = container.resolve(Baz); + + await container.dispose(); + + expect(foo.disposed).toBeTruthy(); + expect(baz.disposed).toBeTruthy(); + }); + it("disposes all instances of the same type", () => { const container = globalContainer.createChildContainer(); diff --git a/src/dependency-container.ts b/src/dependency-container.ts index 8751a24..fb74644 100644 --- a/src/dependency-container.ts +++ b/src/dependency-container.ts @@ -467,9 +467,19 @@ class InternalDependencyContainer implements DependencyContainer { }); } - public dispose(): void { + public async dispose(): Promise { this.disposed = true; - this.disposables.forEach(disposable => disposable.dispose()); + + const promises: Promise[] = []; + this.disposables.forEach(disposable => { + const maybePromise = disposable.dispose(); + + if (maybePromise) { + promises.push(maybePromise); + } + }); + + await Promise.all(promises); } private getRegistration(token: InjectionToken): Registration | null { diff --git a/src/types/dependency-container.ts b/src/types/dependency-container.ts index 455c541..25aface 100644 --- a/src/types/dependency-container.ts +++ b/src/types/dependency-container.ts @@ -126,5 +126,5 @@ export default interface DependencyContainer extends Disposable { * Calls `.dispose()` on all disposable instances created by the container. * After calling this, the container may no longer be used. */ - dispose(): void; + dispose(): Promise | void; } diff --git a/src/types/disposable.ts b/src/types/disposable.ts index 374052d..9ffcebe 100644 --- a/src/types/disposable.ts +++ b/src/types/disposable.ts @@ -1,5 +1,5 @@ export default interface Disposable { - dispose(): void; + dispose(): Promise | void; } export function isDisposable(value: any): value is Disposable {