Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [3.1.5] - 2026-02-23

### Fixed

- Fixed singleton disposal — root provider now disposes singleton `IDisposable` instances when disposed, matching MS DI behaviour
- Added `IDisposable` implementation to `IServiceProvider` interface

## [3.1.4] - 2026-02-22

### Fixed
Expand Down Expand Up @@ -169,6 +176,7 @@

Initial release.

[3.1.5]: https://github.com/shellicar/core-di/releases/tag/3.1.5
[3.1.4]: https://github.com/shellicar/core-di/releases/tag/3.1.4
[3.1.3]: https://github.com/shellicar/core-di/releases/tag/3.1.3
[3.1.2]: https://github.com/shellicar/core-di/releases/tag/3.1.2
Expand Down
8 changes: 2 additions & 6 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,9 @@ Fixed in v3.1.4. All circular dependencies now throw `CircularDependencyError`.

Syncpack 13→14 completed in PR #20, resolving minimatch ReDoS CVE (CVE-2026-26996). Remaining minor/patch updates (`@biomejs/biome`, `turbo`, `@types/node`, `npm-check-updates`, `lefthook`) can be done in a future maintenance pass.

### 3. Singleton disposal verification — Priority: 4
### ~~3. Singleton disposal — Done~~

| Value | Cost | Priority |
|-------|------|----------|
| 4 | 1 | 4 |

Currently `Transient` and `Scoped` `IDisposable` instances are tracked and disposed when the provider/scope is disposed. Singleton instances are **not** disposed (see `ServiceProvider.ts:105`). This mirrors MS DI behaviour where singleton disposal is the responsibility of the root container's disposal. Write a test or two to confirm, then document the expected lifecycle.
Fixed: root provider now disposes singleton `IDisposable` instances when disposed, matching MS DI behaviour. Scoped providers still correctly skip singleton disposal. 4 tests added covering singleton, scoped, and transient disposal lifecycle.

### 4. Separate resolution graph from resolution — Priority: 1.5

Expand Down
2 changes: 1 addition & 1 deletion packages/core-di/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@shellicar/core-di",
"private": false,
"version": "3.1.4",
"version": "3.1.5",
"type": "module",
"license": "MIT",
"author": "Stephen Hellicar",
Expand Down
3 changes: 2 additions & 1 deletion packages/core-di/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ export abstract class IScopedProvider extends IResolutionScope implements IDispo
public abstract [Symbol.dispose](): void;
}

export abstract class IServiceProvider extends IResolutionScope {
export abstract class IServiceProvider extends IResolutionScope implements IDisposable {
public abstract readonly Services: IServiceCollection;
public abstract createScope(): IScopedProvider;
public abstract [Symbol.dispose](): void;
}

export abstract class IServiceCollection {
Expand Down
2 changes: 1 addition & 1 deletion packages/core-di/src/private/ServiceCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,6 @@ export class ServiceCollection implements IServiceCollection {
}

public buildProvider(): IServiceProvider {
return new ServiceProvider(this.logger, this.clone());
return ServiceProvider.createRoot(this.logger, this.clone());
}
}
27 changes: 21 additions & 6 deletions packages/core-di/src/private/ServiceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Lifetime, ResolveMultipleMode } from '../enums';
import { CircularDependencyError, MultipleRegistrationError, SelfDependencyError, ServiceCreationError, UnregisteredServiceError } from '../errors';
import { type IDisposable, IResolutionScope, IScopedProvider, type IServiceCollection, IServiceProvider } from '../interfaces';
import type { ILogger } from '../logger';
import { createRegistrationMap, type ServiceDescriptor, type ServiceIdentifier, type ServiceImplementation, type ServiceRegistration, type SourceType } from '../types';
import { createRegistrationMap, type RegistrationMap, type ServiceDescriptor, type ServiceIdentifier, type ServiceImplementation, type ServiceRegistration, type SourceType } from '../types';
import { DesignDependenciesKey } from './constants';
import { getMetadata } from './metadata';
import { ResolutionContext } from './ResolutionContext';
Expand All @@ -11,16 +11,27 @@ export class ServiceProvider implements IServiceProvider, IScopedProvider {
private scoped = createRegistrationMap();
private created: IDisposable[] = [];

constructor(
private constructor(
private readonly logger: ILogger,
public readonly Services: IServiceCollection,
private readonly singletons = createRegistrationMap(),
private readonly singletons: RegistrationMap<any>,
private readonly singletonDisposables: IDisposable[],
private readonly isRoot: boolean,
) {}

public static createRoot(logger: ILogger, services: IServiceCollection): IServiceProvider {
return new ServiceProvider(logger, services, createRegistrationMap(), [], true);
}

[Symbol.dispose]() {
for (const x of this.created) {
x[Symbol.dispose]();
}
if (this.isRoot) {
for (const x of this.singletonDisposables) {
x[Symbol.dispose]();
}
}
}

private resolveInternal<T extends SourceType>(descriptor: ServiceDescriptor<T>, context: ResolutionContext, serviceIdentifier?: ServiceIdentifier<T>): T {
Expand Down Expand Up @@ -110,14 +121,18 @@ export class ServiceProvider implements IServiceProvider, IScopedProvider {
}
throw new ServiceCreationError(identifier, undefined, descriptor.implementation);
}
if (descriptor.lifetime !== Lifetime.Singleton && Symbol.dispose in instance) {
this.created.push(instance as IDisposable);
if (Symbol.dispose in instance) {
if (descriptor.lifetime === Lifetime.Singleton) {
this.singletonDisposables.push(instance as IDisposable);
} else {
this.created.push(instance as IDisposable);
}
}
return instance;
}

public createScope(): IScopedProvider {
return new ServiceProvider(this.logger, this.Services.clone(true), this.singletons);
return new ServiceProvider(this.logger, this.Services.clone(true), this.singletons, this.singletonDisposables, false);
}

private setDependencies<T extends SourceType>(implementation: ServiceRegistration<T>, instance: T, context: ResolutionContext, serviceIdentifier?: ServiceIdentifier<T>): T {
Expand Down
4 changes: 2 additions & 2 deletions packages/core-di/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export type ServiceBuilderOptions<T extends SourceType> = {
export type RegistrationMap<T extends SourceType = any> = Map<ServiceRegistration<T>, T>;
export type DescriptorMap<T extends SourceType = any> = Map<ServiceIdentifier<T>, ServiceDescriptor<T>[]>;

export const createRegistrationMap = <T extends SourceType = any>() => {
export const createRegistrationMap = <T extends SourceType = any>(): RegistrationMap<T> => {
return new Map<ServiceRegistration<T>, T>();
};

export const createDescriptorMap = <T extends SourceType = any>() => {
export const createDescriptorMap = <T extends SourceType = any>(): DescriptorMap<T> => {
return new Map<ServiceIdentifier<T>, ServiceDescriptor<T>[]>();
};
70 changes: 70 additions & 0 deletions packages/core-di/test/disposal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { createServiceCollection, type IDisposable } from '../src';

abstract class IDisposableService {
abstract get disposed(): boolean;
}

class DisposableService implements IDisposableService, IDisposable {
#disposed = false;
get disposed() {
return this.#disposed;
}
[Symbol.dispose]() {
this.#disposed = true;
}
}

describe('Disposal', () => {
describe('Singleton lifetime', () => {
it('does not dispose singleton when scoped provider is disposed', () => {
const services = createServiceCollection();
services.register(IDisposableService).to(DisposableService).singleton();
const provider = services.buildProvider();
const scoped = provider.createScope();

const instance = scoped.resolve(IDisposableService);
scoped[Symbol.dispose]();

expect(instance.disposed).toBe(false);
});

it('disposes singleton when root provider is disposed', () => {
const services = createServiceCollection();
services.register(IDisposableService).to(DisposableService).singleton();
const provider = services.buildProvider();

const instance = provider.resolve(IDisposableService);
provider[Symbol.dispose]();

expect(instance.disposed).toBe(true);
});
});

describe('Scoped lifetime', () => {
it('disposes scoped instance when scoped provider is disposed', () => {
const services = createServiceCollection();
services.register(IDisposableService).to(DisposableService).scoped();
const provider = services.buildProvider();
const scoped = provider.createScope();

const instance = scoped.resolve(IDisposableService);
scoped[Symbol.dispose]();

expect(instance.disposed).toBe(true);
});
});

describe('Transient lifetime', () => {
it('disposes transient instance when provider is disposed', () => {
const services = createServiceCollection();
services.register(IDisposableService).to(DisposableService).transient();
const provider = services.buildProvider();

const instance = provider.resolve(IDisposableService);
provider[Symbol.dispose]();

expect(instance.disposed).toBe(true);
});
});
});
Loading