From bae9920ce3894090e35e2309326a392e858f0556 Mon Sep 17 00:00:00 2001 From: JD Conley Date: Fri, 12 Sep 2025 07:59:27 -0700 Subject: [PATCH 1/3] resolveAll() function to pre-cache Singletons --- src/InjectorImpl.ts | 20 ++- src/api/Injector.ts | 20 +++ test/unit/Injector.spec.ts | 281 +++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 1 deletion(-) diff --git a/src/InjectorImpl.ts b/src/InjectorImpl.ts index a932d3f..d9d861b 100644 --- a/src/InjectorImpl.ts +++ b/src/InjectorImpl.ts @@ -50,7 +50,7 @@ const DEFAULT_SCOPE = Scope.Singleton; */ abstract class AbstractInjector implements Injector { - private childInjectors: Set> = new Set(); + protected childInjectors: Set> = new Set(); public injectClass[]>( Class: InjectableClass, @@ -142,6 +142,14 @@ abstract class AbstractInjector implements Injector { return this.resolveInternal(token, target); } + public async resolveAll(): Promise { + const promises: Promise[] = []; + for (const child of this.childInjectors) { + promises.push(child.resolveAll()); + } + await Promise.all(promises); + } + protected throwIfDisposed(injectableOrToken: InjectionTarget) { if (this.isDisposed) { throw new InjectorDisposedError(injectableOrToken); @@ -256,6 +264,16 @@ abstract class ChildWithProvidedInjector< } } + public override async resolveAll(): Promise { + this.throwIfDisposed(this.token); + if (this.scope === Scope.Singleton && !this.cached) { + const value = this.result(undefined); + this.addToCacheIfNeeded(value); + this.registerProvidedValue(value); + } + await super.resolveAll(); + } + private addToCacheIfNeeded(value: TProvided) { if (this.scope === Scope.Singleton) { this.cached = { value }; diff --git a/src/api/Injector.ts b/src/api/Injector.ts index 03a7d18..50e6b0d 100644 --- a/src/api/Injector.ts +++ b/src/api/Injector.ts @@ -83,6 +83,26 @@ export interface Injector { **/ createChildInjector(): Injector; + /** + * Resolve all tokens in the current dispose scope, pre-caching all singleton instances. + * This will traverse through all child injectors and resolve any ClassProvider and FactoryProvider + * instances that have Singleton scope, ensuring they are cached for future use. + * @example + * ```ts + * const injector = createInjector() + * .provideClass('database', Database, Scope.Singleton) + * .provideFactory('logger', createLogger, Scope.Singleton); + * + * // Pre-cache all singleton instances + * await injector.resolveAll(); + * + * // These will now use cached instances + * const db = injector.resolve('database'); + * const logger = injector.resolve('logger'); + * ``` + */ + resolveAll(): Promise; + /** * Explicitly dispose the `injector`. * @see {@link https://github.com/nicojs/typed-inject?tab=readme-ov-file#disposing-provided-stuff} diff --git a/test/unit/Injector.spec.ts b/test/unit/Injector.spec.ts index 7c7407e..6e5448a 100644 --- a/test/unit/Injector.spec.ts +++ b/test/unit/Injector.spec.ts @@ -619,6 +619,287 @@ describe('InjectorImpl', () => { }); }); + describe('resolveAll', () => { + it('should pre-cache all singleton instances in the current scope', async () => { + // Arrange + const singletonSpy = sinon.spy(); + const transientSpy = sinon.spy(); + + class SingletonService { + constructor() { + singletonSpy(); + } + } + + function createTransientService() { + transientSpy(); + return { transient: true }; + } + + // Create separate injectors to test resolveAll on each + const singletonInjector = rootInjector.provideClass( + 'singleton', + SingletonService, + Scope.Singleton, + ); + + const bothInjector = singletonInjector.provideFactory( + 'transient', + createTransientService, + Scope.Transient, + ); + + // Act - resolveAll on the singleton injector + await singletonInjector.resolveAll(); + + // Assert - singleton should be cached + expect(singletonSpy.called).to.be.true; + + // Verify cached by resolving again + singletonInjector.resolve('singleton'); + expect(singletonSpy.callCount).to.equal(1); // Still only called once + + // Act - resolveAll on the transient injector + await bothInjector.resolveAll(); + + // Assert - transient should not be cached + expect(transientSpy.called).to.be.false; + }); + + it('should work with nested injectors', async () => { + // Arrange + const service1Spy = sinon.spy(); + const service2Spy = sinon.spy(); + const service3Spy = sinon.spy(); + + class Service1 { + constructor() { + service1Spy(); + } + } + + class Service2 { + constructor() { + service2Spy(); + } + } + + class Service3 { + constructor() { + service3Spy(); + } + } + + // Create a chain of injectors + const injector1 = rootInjector.provideClass( + 'service1', + Service1, + Scope.Singleton, + ); + + const injector2 = injector1.provideClass( + 'service2', + Service2, + Scope.Singleton, + ); + + const injector3 = injector2.provideClass( + 'service3', + Service3, + Scope.Singleton, + ); + + // Act - resolveAll on the last injector only resolves itself + await injector3.resolveAll(); + + // Assert - only service3 should be resolved + expect(service3Spy.called).to.be.true; + expect(service2Spy.called).to.be.false; + expect(service1Spy.called).to.be.false; + + // Act - resolveAll on the middle injector + await injector2.resolveAll(); + + // Assert - service2 should now be resolved + expect(service2Spy.called).to.be.true; + expect(service1Spy.called).to.be.false; + + // Act - resolveAll on the first injector + await injector1.resolveAll(); + + // Assert - service1 should now be resolved + expect(service1Spy.called).to.be.true; + }); + + it('should handle dependencies between services', async () => { + // Arrange + const databaseSpy = sinon.spy(); + const loggerSpy = sinon.spy(); + const serviceSpy = sinon.spy(); + + class Database { + constructor() { + databaseSpy(); + } + } + + class Logger { + constructor() { + loggerSpy(); + } + } + + class Service { + constructor( + public readonly db: Database, + public readonly logger: Logger, + ) { + serviceSpy(); + } + public static inject = tokens('database', 'logger'); + } + + const injector = rootInjector + .provideClass('database', Database, Scope.Singleton) + .provideClass('logger', Logger, Scope.Singleton) + .provideClass('service', Service, Scope.Singleton); + + // Act + await injector.resolveAll(); + + // Assert + expect(databaseSpy.called).to.be.true; + expect(loggerSpy.called).to.be.true; + expect(serviceSpy.called).to.be.true; + + // Verify the service got correct dependencies + const service = injector.resolve('service'); + expect(service.db).to.be.instanceOf(Database); + expect(service.logger).to.be.instanceOf(Logger); + }); + + it('should not fail if singleton resolution throws an error', async () => { + // Arrange + class FailingService { + constructor() { + throw new Error('Service initialization failed'); + } + } + + class WorkingService { + constructor() { + // This one works + } + } + + const injector = rootInjector + .provideClass('failing', FailingService, Scope.Singleton) + .provideClass('working', WorkingService, Scope.Singleton); + + // Act & Assert - should not throw + await injector.resolveAll(); + + // The working service should still be cached + const working = injector.resolve('working'); + expect(working).to.be.instanceOf(WorkingService); + + // The failing service should still throw when accessed directly + expect(() => injector.resolve('failing')).to.throw( + 'Service initialization failed', + ); + }); + + it('should work with factory providers', async () => { + // Arrange + const factorySpy = sinon.spy(); + let instanceCount = 0; + + function createService() { + factorySpy(); + return { id: ++instanceCount }; + } + + const injector = rootInjector.provideFactory( + 'service', + createService, + Scope.Singleton, + ); + + // Act + await injector.resolveAll(); + + // Assert + expect(factorySpy.called).to.be.true; + + // Verify cached + const service1 = injector.resolve('service'); + const service2 = injector.resolve('service'); + expect(service1).to.equal(service2); + expect(service1.id).to.equal(1); + }); + + it('should work with value providers', async () => { + // Arrange + const value = { config: 'test' }; + const injector = rootInjector.provideValue('config', value); + + // Act & Assert - should not throw + await injector.resolveAll(); + + // Value providers are not cached, but should not cause issues + const config = injector.resolve('config'); + expect(config).to.equal(value); + }); + + it('should work with child injectors created via createChildInjector', async () => { + // Arrange + const parentSpy = sinon.spy(); + const childSpy = sinon.spy(); + + class ParentService { + constructor() { + parentSpy(); + } + } + + class ChildService { + constructor() { + childSpy(); + } + } + + const parentInjector = rootInjector.provideClass( + 'parentService', + ParentService, + Scope.Singleton, + ); + const childInjector = parentInjector.createChildInjector(); + const childWithService = childInjector.provideClass( + 'childService', + ChildService, + Scope.Singleton, + ); + + // Act - resolveAll on child injector should not resolve parent's providers + await childInjector.resolveAll(); + + // Assert - parent's singletons should NOT be resolved + expect(parentSpy.called).to.be.false; + + // Act - resolveAll on parent injector + await parentInjector.resolveAll(); + + // Assert - parent's singleton should now be resolved + expect(parentSpy.called).to.be.true; + + // Act - resolveAll on child with service + await childWithService.resolveAll(); + + // Assert - child's singleton should be resolved + expect(childSpy.called).to.be.true; + }); + }); + describe('dependency tree', () => { it('should be able to inject a dependency tree', () => { // Arrange From 9737ddd8cb52f673be225b1a27ab7bae13a8e6e1 Mon Sep 17 00:00:00 2001 From: JD Conley Date: Fri, 12 Sep 2025 08:24:22 -0700 Subject: [PATCH 2/3] Update readme --- README.md | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e51d085..adfb7c3 100644 --- a/README.md +++ b/README.md @@ -516,7 +516,18 @@ const baz /*: number*/ = injector.injectFunction(Foo); #### `injector.resolve(token: Token): CorrespondingType` -The `resolve` method lets you resolve tokens by hand. +The `resolve` method allows you to manually resolve a single token from the injector. This is useful when you need to retrieve a dependency without injecting it into a class or function. Use it when you need to retrieve a single dependency programmatically, in scenarios where you want to conditionally resolve dependencies, when integrating with code that doesn't use dependency injection, or for debugging and testing purposes. + +```ts +const logger = injector.resolve('logger'); +const httpClient = injector.resolve('httpClient'); + +// Use the resolved dependencies directly +logger.info('Application started'); +const response = await httpClient.get('/api/data'); +``` + +The resolve method can be compared with injection methods: ```ts const foo = injector.resolve('foo'); @@ -526,6 +537,70 @@ function retrieveFoo(foo: number) { } retrieveFoo.inject = ['foo'] as const; const foo2 = injector.injectFunction(retrieveFoo); + +#### `injector.resolveAll(): Promise` + +The `resolveAll` method pre-caches all singleton-scoped dependencies in the current injector and its child injectors. This is useful for eager initialization and ensuring all dependencies are created upfront. Use it when you want to validate that all dependencies can be created successfully at startup, to pre-warm your application by creating all singletons before handling requests, for detecting circular dependencies or configuration issues early, or when you need predictable initialization order. + +```ts +const rootInjector = createInjector(); +const appInjector = rootInjector + .provideClass('database', Database, Scope.Singleton) + .provideFactory('logger', createLogger, Scope.Singleton) + .provideClass('cache', RedisCache, Scope.Singleton); + +// Pre-initialize all singletons +// Note: this only traverses children, just like dispose, so we keep a reference to the root injector +await rootInjector.resolveAll(); + +// Now all singletons are cached and ready +const db = appInjector.resolve('database'); // Returns cached instance +const logger = appInjector.resolve('logger'); // Returns cached instance +const cache = appInjector.resolve('cache'); // Returns cached instance +``` + +The behavior differs between singleton and transient scopes: + +```ts +const rootInjector = createInjector(); +const mixedInjector = rootInjector + .provideFactory('singleton', () => { + console.log('Creating singleton'); + return { type: 'singleton' }; + }, Scope.Singleton) + .provideFactory('transient', () => { + console.log('Creating transient'); + return { type: 'transient' }; + }, Scope.Transient); + +await rootInjector.resolveAll(); +// Output: "Creating singleton" +// Note: transient is NOT created + +mixedInjector.resolve('singleton'); // No output - uses cached instance +mixedInjector.resolve('transient'); // Output: "Creating transient" +``` + +The method recursively resolves singletons in child injectors: + +```ts +const parentInjector = createInjector() + .provideClass('parentService', ParentService, Scope.Singleton); + +const childInjector = parentInjector + .provideClass('childService', ChildService, Scope.Singleton); + +const grandchildInjector = childInjector + .provideClass('grandchildService', GrandchildService, Scope.Singleton); + +// Resolves all singletons in the entire injector chain +// Note: this follows the same scoping as dispose +await parentInjector.resolveAll(); + +// All of these return cached instances +const parent = grandchildInjector.resolve('parentService'); +const child = grandchildInjector.resolve('childService'); +const grandchild = grandchildInjector.resolve('grandchildService'); ``` #### `injector.provideValue(token: Token, value: R): Injector>` From f59cdb42ba833a1bf7dd50ebf254ed91583855e6 Mon Sep 17 00:00:00 2001 From: JD Conley Date: Fri, 12 Sep 2025 08:28:06 -0700 Subject: [PATCH 3/3] Fix api documentation root injector reference --- src/api/Injector.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/Injector.ts b/src/api/Injector.ts index 50e6b0d..c83c59b 100644 --- a/src/api/Injector.ts +++ b/src/api/Injector.ts @@ -89,12 +89,13 @@ export interface Injector { * instances that have Singleton scope, ensuring they are cached for future use. * @example * ```ts - * const injector = createInjector() + * const rootInjector = createInjector(); + * const injector = rootInjector * .provideClass('database', Database, Scope.Singleton) * .provideFactory('logger', createLogger, Scope.Singleton); * * // Pre-cache all singleton instances - * await injector.resolveAll(); + * await rootInjector.resolveAll(); * * // These will now use cached instances * const db = injector.resolve('database'); @@ -104,7 +105,7 @@ export interface Injector { resolveAll(): Promise; /** - * Explicitly dispose the `injector`. + * Explicitly dispose the `injector` and all it's child injectors. * @see {@link https://github.com/nicojs/typed-inject?tab=readme-ov-file#disposing-provided-stuff} */ dispose(): Promise;