From eb4ccf6cf7112c66aa5482495f030dbaa63ef197 Mon Sep 17 00:00:00 2001 From: bebrasmell Date: Sat, 15 Aug 2026 23:11:55 +0300 Subject: [PATCH] feat: let a parent hold a child container weakly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parent keeps a strong reference to every child it ever produced and releases it only when that child's destroy() runs. That is right by default, but wrong for a host that may build a container speculatively and then drop it without being told to — a React render that never commits retains one such container forever. The new `weakParentLink` option reaches the child through a WeakRef and prunes the registration with a FinalizationRegistry once the child is collected. Reachable children are still destroyed by the parent cascade, so the default path is untouched. Needs both ES2021 primitives; where either is missing it warns once and keeps the strong link. - child() now takes container options, with `parent` always this container - pin the build to target es2015, the floor the README advertises: with no target esbuild emitted esnext, shipping ES2022 class static blocks and ES2021 logical assignment - state what that pin does not cover — module-scope globalThis (ES2020) and the JSR package, which ships src/ rather than the bundle --- CHANGELOG.md | 23 ++ README.md | 14 ++ docs/API.md | 28 +++ src/lib/container/container.ts | 112 +++++++++- .../container/tests/weak-parent-link.spec.ts | 207 ++++++++++++++++++ src/lib/container/types.ts | 23 +- tsup.config.ts | 4 + 7 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 src/lib/container/tests/weak-parent-link.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c453ad1..5c9a83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index a482026..c799aec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 74bb9d3..dfa6901 100644 --- a/docs/API.md +++ b/docs/API.md @@ -29,6 +29,7 @@ new NodeContainer(options?: { measurePerformance?: boolean; instant?: boolean; parent?: iDIContainer; + weakParentLink?: boolean; }) ``` @@ -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 diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index 93057a8..c79b2aa 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -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 void>> | null = + typeof FinalizationRegistry === "undefined" || typeof WeakRef === "undefined" + ? null + : new FinalizationRegistry 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): () => void { + return () => { + ref.deref()?.bootstrap(); + }; +} + +function weakDestroyHook(ref: WeakRef): () => 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. @@ -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(), - ); } } } @@ -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): iDIContainer { if (this.destroyed) throw InjectionError.destroyed(); - return new NodeContainer({ parent: this }); + return new NodeContainer({ ...options, parent: this }); } /** @internal */ diff --git a/src/lib/container/tests/weak-parent-link.spec.ts b/src/lib/container/tests/weak-parent-link.spec.ts new file mode 100644 index 0000000..6adee29 --- /dev/null +++ b/src/lib/container/tests/weak-parent-link.spec.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Illuma } from "../../global"; +import { NodeContainer } from "../container"; + +function childHookCount(container: NodeContainer): number { + return (container as any)._lifecycle._destroyChildCallbacks.size; +} + +const gc = (globalThis as { gc?: () => void }).gc; + +/** + * Yields long enough for V8 to run FinalizationRegistry callbacks, which are + * scheduled on a separate task after a collection. + */ +async function collect(): Promise { + for (let i = 0; i < 3; i++) { + gc?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +describe("weakParentLink", () => { + it("still destroys a reachable child through the parent cascade", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + const child = new NodeContainer({ parent, weakParentLink: true }); + child.bootstrap(); + + expect(child.destroyed).toBe(false); + parent.destroy(); + expect(child.destroyed).toBe(true); + }); + + it("still cascades bootstrap from a parent bootstrapped later", () => { + const parent = new NodeContainer(); + const child = new NodeContainer({ parent, weakParentLink: true }); + + expect(child.bootstrapped).toBe(false); + parent.bootstrap(); + expect(child.bootstrapped).toBe(true); + }); + + it("does not double-destroy a child that was already destroyed", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + const child = new NodeContainer({ parent, weakParentLink: true }); + child.bootstrap(); + child.destroy(); + + expect(() => parent.destroy()).not.toThrow(); + }); + + it("releases the parent registration when the child is destroyed", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + const child = new NodeContainer({ parent, weakParentLink: true }); + child.bootstrap(); + expect(childHookCount(parent)).toBe(1); + + child.destroy(); + expect(childHookCount(parent)).toBe(0); + }); + + it("keeps the default link strong, so behaviour is unchanged without the flag", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + const child = new NodeContainer({ parent }); + child.bootstrap(); + + expect(childHookCount(parent)).toBe(1); + parent.destroy(); + expect(child.destroyed).toBe(true); + }); + + it.runIf(gc)( + "lets an abandoned weakly linked child be collected, pruning the parent hook", + async () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + let ref!: WeakRef; + (() => { + const orphan = new NodeContainer({ parent, weakParentLink: true }); + orphan.bootstrap(); + ref = new WeakRef(orphan); + })(); + + expect(childHookCount(parent)).toBe(1); + + await collect(); + + expect(ref.deref()).toBeUndefined(); + expect(childHookCount(parent)).toBe(0); + }, + ); + + it.runIf(gc)( + "retains an abandoned child forever under the default strong link", + async () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + let ref!: WeakRef; + (() => { + const orphan = new NodeContainer({ parent }); + orphan.bootstrap(); + ref = new WeakRef(orphan); + })(); + + await collect(); + + expect(ref.deref()).toBeDefined(); + expect(childHookCount(parent)).toBe(1); + }, + ); +}); + +describe("child(options)", () => { + it("forwards options to the child", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + const child = parent.child({ weakParentLink: true }) as NodeContainer; + child.bootstrap(); + expect(childHookCount(parent)).toBe(1); + + child.destroy(); + expect(childHookCount(parent)).toBe(0); + }); + + it("keeps itself as the parent even when the options try to name another", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + + const stranger = new NodeContainer(); + stranger.bootstrap(); + + const child = parent.child({ parent: stranger } as never) as NodeContainer; + child.bootstrap(); + + expect(childHookCount(parent)).toBe(1); + expect(childHookCount(stranger)).toBe(0); + + parent.destroy(); + expect(child.destroyed).toBe(true); + }); +}); + +describe("weakParentLink on an engine that lacks the ES2021 primitives", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + Illuma.setLogger(null); + }); + + async function withMissing(global: "WeakRef" | "FinalizationRegistry") { + vi.stubGlobal(global, undefined); + vi.resetModules(); + + const warn = vi.fn(); + Illuma.setLogger({ log: vi.fn(), warn, error: vi.fn() }); + + const { NodeContainer: Fresh } = await import("../container"); + return { Fresh, warn }; + } + + it("falls back to a strong link without WeakRef, and says so once", async () => { + const { Fresh, warn } = await withMissing("WeakRef"); + + const parent = new Fresh(); + parent.bootstrap(); + + const child = new Fresh({ parent, weakParentLink: true }); + child.bootstrap(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("WeakRef")); + + const second = new Fresh({ parent, weakParentLink: true }); + second.bootstrap(); + expect(warn).toHaveBeenCalledTimes(1); + + parent.destroy(); + expect(child.destroyed).toBe(true); + expect(second.destroyed).toBe(true); + }); + + it("falls back without FinalizationRegistry too, rather than never pruning", async () => { + const { Fresh, warn } = await withMissing("FinalizationRegistry"); + + const parent = new Fresh(); + parent.bootstrap(); + + const child = new Fresh({ parent, weakParentLink: true }); + child.bootstrap(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(childHookCount(parent as NodeContainer)).toBe(1); + + parent.destroy(); + expect(child.destroyed).toBe(true); + }); +}); diff --git a/src/lib/container/types.ts b/src/lib/container/types.ts index f14d257..4e1c4b5 100644 --- a/src/lib/container/types.ts +++ b/src/lib/container/types.ts @@ -30,6 +30,26 @@ export interface iContainerOptions { * @default true */ instant?: boolean; + + /** + * When true, the parent holds this container weakly: it is still destroyed + * by the parent's destroy cascade while it stays reachable, but it no longer + * keeps itself alive through that registration and may be garbage collected + * once nothing else references it. + * + * Intended for hosts that may build a container speculatively and then + * discard it without ever getting a chance to call {@link iDIContainer.destroy} + * — a React render that never commits, for example. Under the default strong + * link every such container is retained by its parent forever. + * + * The trade-off: a weakly linked container that is dropped without being + * destroyed will not run its destroy hooks at all, because nothing observes + * that it became unreachable. Only enable it where the host either destroys + * containers explicitly or keeps no resources that need releasing. + * + * @default false + */ + weakParentLink?: boolean; } /** @@ -108,10 +128,11 @@ export interface iDIContainer { /** * Creates a new child DI container that inherits from the current container. * The child container can be used to provide additional providers that are only available within the child context. + * @param options - Optional configuration for the child container. `parent` is always this container. * @returns A new child DI container * @throws {InjectionError} If called before bootstrap or if the container has been destroyed */ - child(): iDIContainer; + child(options?: Omit): iDIContainer; /** * Destroys the container and releases any resources it holds. diff --git a/tsup.config.ts b/tsup.config.ts index 6003548..2b73818 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,6 +3,10 @@ import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts', 'src/testkit.ts', "src/plugins.ts"], format: ['cjs', 'esm'], + // Pinned to the floor the README advertises. Without it esbuild emits + // `esnext`, which shipped ES2022 class static blocks and ES2021 logical + // assignment into a bundle documented as ES2015-compatible. + target: 'es2015', dts: true, splitting: false, sourcemap: true,