Skip to content

Make Container.provides() chain O(N) instead of O(N²)#19

Merged
kburov-sc merged 13 commits into
mainfrom
kburov/provides-chain-perf
May 19, 2026
Merged

Make Container.provides() chain O(N) instead of O(N²)#19
kburov-sc merged 13 commits into
mainfrom
kburov/provides-chain-perf

Conversation

@kburov-sc
Copy link
Copy Markdown
Collaborator

@kburov-sc kburov-sc commented May 14, 2026

Background

Long Container.provides() chains used to construct in O(N²) time (per-step {...this.factories} spread + a constructor for...in rebinding every memoized factory's thisArg). Snap mobile team reported ANRs on container assembly for low-end devices, with traces converging on Container.provides. The same shape applied to PartialContainer.

Change

  • Construction is O(N) per chain. Replace the spread with Object.create(this.factories) + per-token assignment; drop thisArg mutation in favour of call-site this in memoize. Same treatment in PartialContainer, copy(), and the merge paths.
  • Reads stay O(1). A naive Object.create-based chain regressed Container.get() to a prototype-chain walk costing O(depth-of-token) — invisible on V8 (inline caches mask it) but ~20× regression on Hermes per the Snap mobile measurement. Fix: materialize a flat own-property snapshot of factories on the first get() and read from it thereafter.
  • Token names that collide with Object.prototype ("toString", "constructor", "__proto__", …) now resolve only if explicitly registered. The factories map is rooted at a null-prototype object so they don't leak through get().

Behavioral note: each container is now a true snapshot — parent.get('svc') after forking a child no longer resolves dependencies through the child (the old thisArg-mutation code did this). Sibling isolation across forks still requires copy(['token']), unchanged.

Numbers

Chain construction:

N Container before Container after PartialContainer before PartialContainer after
800 32.08 ms 4.27 ms 14.20 ms 2.67 ms
1600 309.44 ms 18.32 ms 134.99 ms 15.90 ms
8000 10,285.6 ms 495.9 ms 5,199.1 ms 512.6 ms

Hot read-path full sweep:

N without flatten cache with flatten cache
800 1.883 ms 0.069 ms
1600 13.313 ms 0.103 ms

Full write-up + reproduction steps: benchmarks/INVESTIGATION.md and npm run bench.

Test plan

  • npm test — 93/93 passing, 100% line/branch/function/statement coverage.
  • npm run compile, npm run styleguide clean.
  • npm run bench confirms linear scaling of both construction and reads.

🤖 Generated with Claude Code

kburov-sc and others added 3 commits May 14, 2026 10:36
Long .provides()..provides() chains caused container assembly ANRs on low-end
devices because each call shallow-copied the entire factories map and the
constructor walked every factory to rebind thisArg. With N services that's
O(N²) work. This patch replaces the spread with prototype-chained extension
(Object.create) and switches memoize to call-site `this`, eliminating the
per-step rebinding pass.

8000-deep chain: 10286 ms -> 496 ms (~21x). All 87 tests pass with 100%
coverage. See benchmarks/INVESTIGATION.md for the full write-up and
benchmarks/provides-chain.ts (npm run bench) to reproduce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal chain-building callers (providesService, copy,
provides(Container|PartialContainer)) always feed pre-memoized factories.
Route them through a trusted private static factory that bypasses the
constructor's per-step scan entirely. The public constructor keeps its
backwards-compatible memoize-as-needed behaviour for raw user input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PartialContainer.provides() had the same O(N²) spread pattern. Apply the
same prototype-chain extension via Object.create on this.injectables.
PartialContainer chain at 8000 services: 5199 ms -> 513 ms (~10x).

Also: for...in and naive [k] lookup on a deep Object.create chain are
themselves O(N²) in V8 (key deduplication). Introduce a chainedForEach
helper that walks the chain in a single linear pass, and use it from all
sites that traverse a potentially-chained map (Container.provides merge,
PartialContainer.provides + getFactories + getTokens).

The benchmark now also covers PartialContainer construction and
materialization. Investigation doc updated with the new numbers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kburov-sc and others added 4 commits May 14, 2026 12:04
CI's format:check (prettier -l *) was failing on the changes from earlier
commits. No functional changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The factories map was inheriting from Object.prototype, so c.get("toString")
on a container that hadn't registered the token would silently return the
result of Object.prototype.toString instead of throwing — a regression
introduced when we switched to Object.create-based chains. Root every
factory chain at a null-prototype object so token names that match
Object.prototype methods don't leak through get().

Add tests for four use cases that 100% line coverage didn't pin down:
- A service that declares $container as a dependency receives the
  container that resolved it (including after a fork with override).
- Tokens that shadow Object.prototype methods (toString, hasOwnProperty,
  constructor) resolve to their registered values, while unregistered
  ones throw.
- A 100-deep linear dependency chain resolves to the correct values.
- copy(['token']) on a deeply chained container produces a fresh
  memoization for the scoped service while non-scoped services stay
  shared with the original.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR-introduced regression: switching from `{...this.factories}` to
`Object.create(this.factories)` made provides() construction O(1) per step
but turned every Container.get() into a prototype-chain walk costing
O(depth-of-token). V8's inline caches hide it; on Hermes (Snap mobile /
Composer, no JIT) the team measured a ~20x regression on a full key sweep
of an 800-deep chain.

Fix: materialize a flat own-property snapshot of `factories` on the first
get() and read from it thereafter. The factories chain itself stays
prototype-linked so provides() construction remains O(1). Container
instances are immutable after construction, so the cached snapshot is
valid for the container's lifetime.

Hot read path full sweep on V8 (Node): N=1600 drops from 13.3 ms/iter to
0.10 ms/iter (~129x). Curve is now linear in N. Construction numbers
unchanged (within ~10% noise).

Adds benchmarks/get-pass.ts + `npm run bench:get` to track this in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the separate `bench:get` script; `npm run bench` now runs both
construction and read-path benchmarks back-to-back via benchmarks/index.ts.
One command, complete perf picture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nbanks-sc
nbanks-sc previously approved these changes May 14, 2026
Three regressions surfaced in PR review, all rooted in the
Object.create-based chain leaking into externally observable behavior:

1. Container.factories observable shape changed. It was a flat
   own-property map; the chain switch made Object.keys(c.factories) return
   only the most recent layer. Fix: rename internal storage to
   `factoriesChain` (private) and expose `factories` as a lazy flat getter
   built on first access. External readers see the full set of tokens
   again; internal chain extension and the existing flat-cache lookup in
   get() continue to work unchanged.

2. Constructor skipped memoizing inherited factories. The slow path only
   scanned Object.keys(input), so `new Container(Object.create(parent))`
   left inherited raw factories un-memoized — breaking singleton semantics
   and crashing copy() (no .delegate to un-memoize). Fix: iterate via
   chainedForEach so own + inherited are both memoized into a clean
   null-rooted own-property root.

3. PartialContainer.__proto__ tokens disappeared. addInjectable did
   `Object.create(this.injectables)` where this.injectables inherited
   Object.prototype, so assigning to "__proto__" triggered the inherited
   setter and silently mutated the prototype. Fix: same null-rooted
   normalization as Container, plus a private withInjectables fast path
   that skips re-normalization for internal chain builders. Also
   null-rooted the temporary factories object built inside
   PartialContainer.getFactories so the materialized form preserves
   "__proto__" too.

Adds one test per regression. 100% coverage holds at 96/96.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kburov-sc and others added 3 commits May 14, 2026 16:38
Before PR #19 the public Container.factories map held memoized factories
that carried a `thisArg`, so calling `c.factories.svc()` directly (without
going through `get()`) would still resolve dependencies through the
container. After the chain switch the factory body uses call-site `this`,
which means a direct invocation makes `this` the factories map itself —
`this.get(...)` then throws for any service with dependencies.

Fix in `buildFlatFactories`: when materializing the public flat view, wrap
each factory in a tiny forwarder that calls the underlying memoized
factory with the container as `this`. Memo state stays in the underlying
factory's closure (so memoization is preserved and shared across the
chain), and both `get()` and a direct `c.factories[token]()` now route
through the same container — matching the old public contract.

Adds a test that exercises a dependent service through the public
factories map. 97/97 tests pass, 100% coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pin down the public-contract properties the previous commit re-established
so they can't silently regress:
- get() and direct factories[token]() share memoization (single run).
- Direct invocation works for a service inherited via a deep chain.
- Direct invocation picks up dependency overrides applied later in the
  chain (matching the get() codepath and pre-PR thisArg behavior).

100/100 tests pass, 100% coverage held.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrite the two comments that anchored their explanation to "Pre-PR-#19"
and "the pre-PR thisArg behavior" so each test describes the contract
in terms of the current API. PR numbers age out; the test names already
capture the property under test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ecoronadosc
ecoronadosc previously approved these changes May 14, 2026
Copy link
Copy Markdown
Collaborator

@ecoronadosc ecoronadosc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this improves latency until first render requested.

The Container.provides() refactor in this branch changes externally-
observable behavior in two ways (parent containers stay a self-consistent
snapshot after a child fork; token names that collide with
Object.prototype methods no longer leak through get()). Major bump per
the project's release guide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@msilivonik-sc
Copy link
Copy Markdown
Collaborator

Behavioral change worth documenting + testing

This PR changes observable behavior for the following pattern:

const base = Container
  .providesValue("number", 1)
  .provides("app", ["number"], (number) => ({ number }));

base.providesValue("number", 2); // return value intentionally discarded
console.log(base.get("app"));

Version Result
Before this PR { number: 2 } — creating a child container mutated base's memoized factory via thisArg, so the override leaked back into base
After this PR { number: 1 } — providesValue is now fully non-mutating; base is unaffected

The new behavior is clearly correct (and matches the intent described in the old constructor comment), but it's a breaking change for any caller that accidentally relied on the mutation side-effect.

Three asks before merge:

  1. Add a regression test for this exact pattern — get() on the parent after a discarded providesValue() call should return the parent's own value, not the child's.

  2. Update docs / README anywhere that describes provides/providesValue chaining or child-container semantics — make it explicit that the returned container is the only thing that sees the override, and calling sites that discard the return value get no effect.

No objection to the fix itself — the old behavior was an implementation accident and the new code is the right shape.

@msilivonik-sc
Copy link
Copy Markdown
Collaborator

As per offline converstaion the test already exists! Let's add a note before this line here:

Note: Each registration method (provides, providesValue, providesClass, etc.)
returns a new child container — the original container is never modified.
Always use the returned value; calls whose return value is discarded have no effect.

msilivonik-sc
msilivonik-sc previously approved these changes May 19, 2026
Copy link
Copy Markdown
Collaborator

@msilivonik-sc msilivonik-sc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Nice change!

Make the immutable-on-update semantic explicit so readers don't expect the
original container to be mutated by `.providesValue(...)`, `.provides(...)`,
etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kburov-sc kburov-sc merged commit ec452af into main May 19, 2026
1 check passed
@kburov-sc kburov-sc deleted the kburov/provides-chain-perf branch May 19, 2026 14:36
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.

4 participants