Skip to content

chore: child to parent singleton bleed#14

Draft
brunozoric wants to merge 12 commits into
mainfrom
bruno/refactor/child-parent-singleton-bleed
Draft

chore: child to parent singleton bleed#14
brunozoric wants to merge 12 commits into
mainfrom
bruno/refactor/child-parent-singleton-bleed

Conversation

@brunozoric

@brunozoric brunozoric commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes singleton bleed-through in parent/child container hierarchies and introduces a new GlobalScope lifetime for shared resources.

When a singleton registered in a parent container depends on { multiple: true } abstractions, child container registrations currently leak into the parent's cached singleton instance — and from there into every other container in the hierarchy.

Root cause: resolveRegistration caches singletons in this.instances (the owning container) but resolves dependencies via resolveFrom (the requesting container). A child resolving a parent-owned singleton collects child-scoped registrations, then writes the result into the parent's cache — polluting it for all subsequent resolvers.

Fix: Two new lifetime scopes replace the broken singleton behavior:

  • Singleton scope (per-container) — cache singletons in resolveFrom.instances instead of this.instances, apply decorator chains in parent-to-child order. Each container gets its own instance reflecting its own dependency graph.
  • Global scope (shared downward) — resolve deps and decorators exclusively from the owning container. Cache in the resolving container with walk-up lookup so children reuse an ancestor's cached instance.

What changes

Core (src/Container.ts, src/types.ts)

  1. LifetimeScope.Global — new enum value in src/types.ts
  2. RegistrationBuilder.inGlobalScope() — new builder method
  3. Singleton cache lookup/writeresolveFrom.instances instead of this.instances
  4. Singleton decorator chain — after the owning container applies its decorators, walk from resolveFrom up to this and apply each intermediate container's decorators in parent-to-child order. Decorator constructor deps at every level are resolved from resolveFrom.
  5. Global cache lookup — walk up from resolveFrom through ancestors, stop at owning container. Return first cached instance found.
  6. Global dep/decorator resolution — use this (owning container) for both dep resolution and applyDecorators, passing this as resolveFrom so decorator constructor deps also come from the owner.
  7. Falsy singleton fix — change if (existing) to if (existing !== undefined) so singletons resolving to 0, false, "", or null are correctly cached

Three lifetime scopes

Scope Instance per Deps resolved from Decorators Cache lookup Sharing
Transient Every call Resolving container Owning container classes, resolver deps None None
Singleton Container Resolving container Full chain classes (owner->resolver), resolver deps Resolving container only Never — each container gets its own
Global First resolver + shared downward Owning container Owning container classes, owning container deps Walk up from resolver Downward — children reuse ancestor's cached instance

Usage

// Per-container: each container gets its own instance with its own deps
container.register(ProductRegistryImpl).inSingletonScope();

// Shared: one instance, resolved from owning container, shared downward
container.register(SqlConnectionImpl).inGlobalScope();

Singleton scope behavior

Parent: ProductRegistry (singleton), CoffeeProduct, ComputerProduct
Child:  CarProduct

# Before (broken)
child.resolve(ProductRegistry)   -> [Coffee, Computer, Car] — cached in parent
parent.resolve(ProductRegistry)  -> [Coffee, Computer, Car] — WRONG, Car leaked

# After (fixed)
child.resolve(ProductRegistry)   -> [Coffee, Computer, Car] — cached in child
parent.resolve(ProductRegistry)  -> [Coffee, Computer]      — cached in parent, unpolluted

Global scope behavior

# Case A: parent resolves first — children share instance
parent.resolve(SqlConnection)  -> constructs, caches in parent
child1.resolve(SqlConnection)  -> walks up, finds in parent -> same instance
child2.resolve(SqlConnection)  -> walks up, finds in parent -> same instance

# Case B: child resolves first — independent construction, shared downward
child1.resolve(SqlConnection)  -> walks up, no cache, constructs, caches in child1
parent.resolve(SqlConnection)  -> no cache in parent, constructs, caches in parent
grandchild.resolve(SqlConnection) -> walks up, finds in child1 -> child1's instance

Tests

  • Update existingsingletons.test.ts, registry.test.ts: cross-container toBe identity assertions updated to assert behavioral equivalence within the same container
  • New: singletonBleed.test.ts — bleed-through prevention, child inheritance, sibling isolation, deep hierarchy, same-container identity
  • New: singletonCrossResolution.test.ts — singleton variants of all childContainer.test.ts scenarios (override some/all/no deps, grandchild chain, great-grandchild 4-level hierarchy)
  • New: singletonDecoratorChain.test.ts — child/grandchild decorator application, intermediate decorator deps from requesting container, parent isolation from child decorators
  • Resolution ordering tests — both-siblings-before-parent, parent-before-child, singleton dependency chains, pre-cached dependency ordering
  • New: global scope tests — parent-first sharing, child-first independent construction, walk-up nearest-ancestor cache hit, child decorators ignored, child { multiple: true } registrations ignored, deep hierarchy sharing

Docs

  • AGENTS.md lifetime scopes section updated to reflect all three scopes

Breaking change

This is a breaking semantic change. The singleton contract changes from "one shared instance across the hierarchy" to "one instance per container that resolves it."

  • Code that relies on child.resolve(X) === parent.resolve(X) (reference identity across containers) will break
  • Within the same container, reference identity is preserved
  • Code that needs the old shared behavior should migrate to .inGlobalScope()

Design docs (on this branch)

  • docs/2026-05-26-per-container-singleton-scoping-design.md — full design spec covering both singleton and global scope, with before/after code, decorator ordering, concrete examples, and alternatives considered (reviewed through 4 passes, 20 issues found and fixed)
  • docs/superpowers/plans/2026-05-26-per-container-singleton-scoping.md — task-by-task implementation plan
  • docs/2026-05-26-container-hardening-design.md — cold review findings (falsy cache bug, circular dep check bypass, perf issues)

Test plan

  • All existing tests pass (with updated identity assertions)
  • New bleed-through tests pass — child registrations never appear in parent singleton
  • Sibling isolation — two children with different registrations get independent singletons
  • Deep hierarchy — grandchild sees parent + child + own registrations, parent is untouched
  • Singleton decorator chain — child decorators applied in parent-to-child order, parent unaffected
  • Intermediate decorator deps — resolved from requesting container, not intermediate container
  • Resolution ordering — parent-before-child and child-before-parent both produce correct isolated instances
  • Singleton dependency chain — pre-cached parent dep does not leak into child's singleton
  • Same-container identity — resolving twice from the same container returns toBe-identical instance
  • Falsy singleton values (0, false, "", null) are cached correctly
  • Global scope: parent-first — all children share parent's instance
  • Global scope: child-first — child gets its own, parent gets its own, grandchild shares child's via walk-up
  • Global scope: child decorators and { multiple: true } registrations have no effect
  • Global scope: walk-up returns nearest ancestor's cache, not owning container's

@brunozoric brunozoric self-assigned this May 26, 2026
brunozoric and others added 11 commits May 26, 2026 10:47
Describes the singleton bleed-through bug where child container
registrations pollute a parent's cached singleton, and the fix:
cache singletons per-resolving-container instead of per-owning-container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds singleton variants of all childContainer.test.ts scenarios
to ensure cross-resolution behavior is bulletproof under singleton scoping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add child decorator chain application to the singleton scoping fix
- Add ProductRegistry example with parent/child/grandchild showing
  before/after behavior with 6 products and 2 decorators
- Add registry.test.ts to impacted test list (found by code review)
- Add 6 additional test scenarios: decorator chain, resolution ordering,
  singleton dependency chains
- Document as breaking semantic change requiring semver bump

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6 tasks covering: failing tests, Container.ts fix, existing test
updates, singleton cross-resolution tests, decorator chain tests,
and documentation updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cold review findings: falsy singleton cache bug, reflect-metadata
side-effect in types.ts, circular { multiple: true } stack overflow,
dead prettier scripts, undocumented composite/resolveAll behavior.

6 tasks covering: cache fix, reflect-metadata cleanup, prettier
removal, circular depth guard, builder fluency, AGENTS.md docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds LifetimeScope.Global design (owner-context resolution, walk-up
cache lookup, downward sharing) and fixes 20 issues found across 4
review passes including incorrect assertions, missing breaking tests,
and table inaccuracies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant