Skip to content
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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- `iContainerOptions.weakParentLink` — opt-in weak parent-to-child link. The parent
reaches such a child through a `WeakRef` and a `FinalizationRegistry` prunes the
registration once the child is collected, so a container that is built and then
abandoned without `destroy()` no longer pins itself to its parent for the parent's
whole lifetime. Reachable children are still destroyed by the parent's cascade.
Default (`false`) behaviour is unchanged. Needs both `WeakRef` and
`FinalizationRegistry` (ES2021); where either is missing the option warns once and
falls back to the strong link.
- `iDIContainer.child()` now accepts container options, so a child can be created with
`instant` or `weakParentLink` without reaching for the `NodeContainer` constructor.

### Changed

- The build now pins `target: 'es2015'`, matching the syntax floor the README has always
advertised. Without an explicit target esbuild emitted `esnext`, so the published bundle
carried ES2022 class static blocks and ES2021 logical assignment despite documenting an
ES2015 floor. Costs ~0.7 KB gzipped. `README.md` now also states what the pin does *not*
cover — the module-scope `globalThis` reads (ES2020) and the JSR package, which ships
`src/` rather than the bundle — and the one option (`weakParentLink`) that deliberately
reaches past the floor.

## 2.4.0 - 2026-06-30
### Added
- `iNodeTokenBaseOptions.global` — opt-in token-instance deduplication by name via
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ Compatible with virtually anything supporting ES2015+ (ES6+).
Practically the library is compatible with Node.js (v14+), Bun, Deno and all modern browsers.
For older environments, consider using a transpiler or provide polyfills as needed.

The npm bundle pins its **syntax** to that floor by the build (`target: 'es2015'`).
Two things it does not cover:

- The library reads `globalThis` at module scope, which is ES2020. Older engines
need a `globalThis` polyfill — a transpiler alone will not do.
- The JSR package publishes `src/`, not the bundle, so the syntax pin does not
apply there; JSR consumers compile the sources themselves.

One container option reaches past the floor deliberately. `weakParentLink` needs
`WeakRef` and `FinalizationRegistry` (ES2021 — Node.js 14.6+, Chrome 84+,
Firefox 79+, Safari 14.1+). Where either is missing the option logs a warning
once and falls back to the default strong parent link; nothing else in the
library requires them.

## Quick start

```typescript
Expand Down
28 changes: 28 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ new NodeContainer(options?: {
measurePerformance?: boolean;
instant?: boolean;
parent?: iDIContainer;
weakParentLink?: boolean;
})
```

Expand All @@ -37,6 +38,33 @@ new NodeContainer(options?: {
| `options.measurePerformance` | `boolean` | `false` | Enable performance monitoring |
| `options.instant` | `boolean` | `true` | Whether to instantiate consumers immediately on bootstrap (true) or lazily (false) |
| `options.parent` | `iDIContainer` | `undefined` | Optional parent container for hierarchical injection |
| `options.weakParentLink` | `boolean` | `false` | Let the parent hold this container weakly, so an abandoned child can be garbage collected |

#### `weakParentLink`

By default a parent keeps a strong reference to every child it ever produced, and
releases it only when that child's `destroy()` runs. That is the right default: it
guarantees the destroy cascade reaches everything.

It is the wrong default for a host that may build a container speculatively and then
drop it without ever being told to — a React render that never commits, for instance.
Every such container would be retained for the parent's whole lifetime.

With `weakParentLink: true` the parent reaches the child through a `WeakRef`, and a
`FinalizationRegistry` prunes the registration once the child is collected. A child that
is still reachable is still destroyed by the parent's cascade, exactly as before.

The trade-off: a weakly linked container that is dropped without `destroy()` never runs
its destroy hooks, because nothing observes that it became unreachable. Enable it only
where the host destroys containers explicitly, or where services hold no resource that
needs releasing — acquire those on a mount hook rather than in a constructor.

Requires `WeakRef` (ES2021). Where it is unavailable the option warns once and falls
back to the strong link.

```typescript
const scoped = new NodeContainer({ parent: root, weakParentLink: true });
```

### Methods

Expand Down
112 changes: 102 additions & 10 deletions src/lib/container/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,88 @@ import { Injector, InjectorImpl } from "../utils/injector";
import { LifecycleRef, LifecycleRefImpl } from "./lifecycle";
import type { iContainerOptions, iDIContainer } from "./types";

/**
* Prunes a weakly linked child's parent registrations once the child itself has
* been collected, so a parent that outlives many discarded children does not
* accumulate dead hook closures.
*
* The held value is the plain array of unsubscribers and the callback closes
* over nothing: a held value that reaches the target, however indirectly, keeps
* it alive forever and silently turns the weak link back into a strong one.
*/
const PARENT_LINK_REGISTRY: FinalizationRegistry<Array<() => void>> | null =
typeof FinalizationRegistry === "undefined" || typeof WeakRef === "undefined"
? null
: new FinalizationRegistry<Array<() => void>>((unsubscribers) => {
for (const unsubscribe of unsubscribers) unsubscribe();
});

/**
* `WeakRef` and `FinalizationRegistry` are ES2021, while the rest of the library
* targets ES2015. Both are required: a `WeakRef` without the registry would stop
* retaining children but would never prune their registrations, quietly trading
* one accumulation for another. An engine missing either keeps the strong link
* rather than failing to build a container at all — the option is an
* optimisation, not a correctness requirement.
*/
const WEAK_PARENT_LINK_SUPPORTED = PARENT_LINK_REGISTRY !== null;

let weakLinkWarned = false;

function warnWeakLinkUnsupported(): void {
if (weakLinkWarned) return;
weakLinkWarned = true;

Illuma.logger.warn(
"[Illuma] `weakParentLink` needs WeakRef and FinalizationRegistry (ES2021), which this environment does not provide. Falling back to a strong parent link: a child dropped without destroy() will be retained by its parent.",
);
}

/**
* Every weak parent hook is built here, at module scope, and never inside the
* constructor.
*
* A JS engine allocates one context per scope, shared by every closure created
* in it. The constructor's other branch builds `() => this.destroy()`, so `this`
* lives in the constructor's context — and any hook created alongside it would
* reach the child through that shared context no matter how carefully it avoids
* naming `this`. These factories capture nothing but the ref.
*/
function weakBootstrapHook(ref: WeakRef<NodeContainer>): () => void {
return () => {
ref.deref()?.bootstrap();
};
}

function weakDestroyHook(ref: WeakRef<NodeContainer>): () => void {
return () => {
const target = ref.deref();
if (target && !target.destroyed) target.destroy();
};
}

function linkWeaklyToParent(
child: NodeContainer,
lifecycle: LifecycleRefImpl,
cascadeBootstrap: boolean,
): { bootstrap?: () => void; destroy: () => void } {
const ref = new WeakRef(child);
const unsubscribers: Array<() => void> = [];

let bootstrap: (() => void) | undefined;
if (cascadeBootstrap) {
bootstrap = lifecycle.onChildBootstrap(weakBootstrapHook(ref));
unsubscribers.push(bootstrap);
}

const destroy = lifecycle.onChildDestroy(weakDestroyHook(ref));
unsubscribers.push(destroy);

PARENT_LINK_REGISTRY?.register(child, unsubscribers, child);

return { bootstrap, destroy };
}

/**
* The main Dependency Injection Container class that holds registered providers
* and resolves instances of those dependencies.
Expand Down Expand Up @@ -80,15 +162,24 @@ export class NodeContainer extends Illuma implements iDIContainer {
}

if (this._parent instanceof NodeContainer) {
if (!this._parent.bootstrapped) {
this._unsubParentBootstrap = this._parent._lifecycle.onChildBootstrap(() =>
this.bootstrap(),
);
const lifecycle = this._parent._lifecycle;

const wantsWeakLink = _opts?.weakParentLink === true;
if (wantsWeakLink && !WEAK_PARENT_LINK_SUPPORTED) warnWeakLinkUnsupported();

if (wantsWeakLink && WEAK_PARENT_LINK_SUPPORTED) {
const link = linkWeaklyToParent(this, lifecycle, !this._parent.bootstrapped);
this._unsubParentBootstrap = link.bootstrap;
this._unsubParentDestroy = link.destroy;
} else {
if (!this._parent.bootstrapped) {
this._unsubParentBootstrap = lifecycle.onChildBootstrap(() =>
this.bootstrap(),
);
}

this._unsubParentDestroy = lifecycle.onChildDestroy(() => this.destroy());
}

this._unsubParentDestroy = this._parent._lifecycle.onChildDestroy(() =>
this.destroy(),
);
}
}
}
Expand Down Expand Up @@ -420,15 +511,16 @@ export class NodeContainer extends Illuma implements iDIContainer {

this._unsubParentBootstrap?.();
this._unsubParentDestroy?.();
PARENT_LINK_REGISTRY?.unregister(this);
this._bootstrapped = false;
this._protoNodes.clear();
this._multiProtoNodes.clear();
}
}

public child(): iDIContainer {
public child(options?: Omit<iContainerOptions, "parent">): iDIContainer {
if (this.destroyed) throw InjectionError.destroyed();
return new NodeContainer({ parent: this });
return new NodeContainer({ ...options, parent: this });
}

/** @internal */
Expand Down
Loading