Make Container.provides() chain O(N) instead of O(N²)#19
Conversation
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>
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>
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>
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
left a comment
There was a problem hiding this comment.
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>
|
Behavioral change worth documenting + testing This PR changes observable behavior for the following pattern:
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:
No objection to the fix itself — the old behavior was an implementation accident and the new code is the right shape. |
|
As per offline converstaion the test already exists! Let's add a note before this line here:
|
msilivonik-sc
left a comment
There was a problem hiding this comment.
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>
Background
Long
Container.provides()chains used to construct in O(N²) time (per-step{...this.factories}spread + a constructorfor...inrebinding every memoized factory'sthisArg). Snap mobile team reported ANRs on container assembly for low-end devices, with traces converging onContainer.provides. The same shape applied toPartialContainer.Change
Object.create(this.factories)+ per-token assignment; dropthisArgmutation in favour of call-sitethisinmemoize. Same treatment inPartialContainer,copy(), and the merge paths.Object.create-based chain regressedContainer.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 offactorieson the firstget()and read from it thereafter.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 throughget().Behavioral note: each container is now a true snapshot —
parent.get('svc')after forking a child no longer resolves dependencies through the child (the oldthisArg-mutation code did this). Sibling isolation across forks still requirescopy(['token']), unchanged.Numbers
Chain construction:
Hot read-path full sweep:
Full write-up + reproduction steps:
benchmarks/INVESTIGATION.mdandnpm run bench.Test plan
npm test— 93/93 passing, 100% line/branch/function/statement coverage.npm run compile,npm run styleguideclean.npm run benchconfirms linear scaling of both construction and reads.🤖 Generated with Claude Code