Skip to content

fix(core): release the throwaway wrapper of each create() call - #18137

Merged
kamilmysliwiec merged 1 commit into
nestjs:masterfrom
maxymlyskov:fix/release-the-throwaway-wrapper-of-create
Oct 13, 2026
Merged

kamilmysliwiec merged 1 commit into
nestjs:masterfrom
maxymlyskov:fix/release-the-throwaway-wrapper-of-create

Conversation

@maxymlyskov

Copy link
Copy Markdown
Contributor

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Other... Please describe:

What is the current behavior?

moduleRef.create() builds a new InstanceWrapper on every call and passes it to the injector as the inquirer (module-ref.ts). Resolving its constructor and property dependencies runs registerDependencyTreeParent, which adds that wrapper to dependencyTreeParents. That's a module-level WeakMap keyed by the dependency's wrapper, but the dependency lives as long as the app and the value is a strong Set. So every throwaway wrapper, and its metatype with it, is never collected.

Transient dependencies make it worse. Each one gets a transientMap entry keyed by the throwaway wrapper's id (<wrapper id>:<transient id> for a nested one) holding its instance under STATIC_CONTEXT. Every transient instance built for a create() call stays alive, and getStaticTransientInstances() keeps returning it, which is the list the lifecycle hooks walk.

A plain Node script against the compiled package shows it with a real NestFactory.createApplicationContext: 200 warm-up + 20 000 create() calls, heap measured after gc. On master:

--- master run 1
[probe] transient instances held at start: TransientDep 0, NestedTransient 0
[probe] create(WithSingleton): 20000 calls, heapUsed +14.3 MB after gc (751 B/call)
[probe] create(WithTransient): 20000 calls, heapUsed +28.4 MB after gc (1489 B/call)
[probe] transient instances held after the create() calls: TransientDep 20200, NestedTransient 20200
[probe] control new WithSingleton(singleton): 20000 calls, heapUsed +0.0 MB after gc (2 B/call)

All 3 runs matched (B/call moved by a few bytes).

Issue Number: N/A

Steps to reproduce

  1. npx vitest run packages/core/test/injector/module-ref.spec.ts on master with the fix reverted (the tests in "when the created class has dependencies")
  2. Expected: once create() settles, its wrapper is gone from the dependencies' parent sets and their transient maps
  3. Actual (raw output on untouched master):
         × should not keep the transient instances it created 22ms
         × should not keep the transient instances of injected properties 8ms
         × should not stay a dependency tree parent of its dependencies 12ms
         × should not stay a dependency tree parent of its dependencies when it rejects 8ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 4 ⎯⎯⎯⎯⎯⎯⎯
AssertionError: expected [ { …(5) }, { …(5) } ] to deeply equal []
AssertionError: expected [ { …(5) } ] to deeply equal []
AssertionError: expected "resetDependencyTreeState" to be called once, but got 2 times
AssertionError: expected "resetDependencyTreeState" to be called once, but got 2 times
      Tests  4 failed | 12 passed (16)

What is the new behavior?

instantiateClass now calls wrapper.detachFromDependencies() in a .finally on the promise it returns, so it runs on resolve and on reject. I put it there because the caller resumes on resolve(instance) inside the injector callback, before loadInstance settles.

detachFromDependencies() removes the wrapper from each constructor and property dependency's parent set, then deletes the transientMap keys equal to its id or starting with <id>:, recursing through transient dependencies. Entries other inquirers own are left alone, and the instance create() returns is the same as before. Same probe on the fix:

--- fix run 1
[probe] transient instances held at start: TransientDep 0, NestedTransient 0
[probe] create(WithSingleton): 20000 calls, heapUsed +0.3 MB after gc (14 B/call)
[probe] create(WithTransient): 20000 calls, heapUsed +0.2 MB after gc (12 B/call)
[probe] transient instances held after the create() calls: TransientDep 0, NestedTransient 0
[probe] control new WithSingleton(singleton): 20000 calls, heapUsed +0.0 MB after gc (1 B/call)

Does this PR introduce a breaking change?

  • Yes
  • No

Other information

The parent registry came in with 1b8c8b0 (propagate dependency tree cache resets) and went module-private in 3ed595e. Nothing removed entries for a wrapper that only lives for one create() call, which is the gap this closes.

Left as is: a transient dependency's rootInquirer still points at the last create() wrapper. That's one per provider, so it stays bounded.

module-ref.spec.ts 16/16, instance-wrapper.spec.ts + module-ref.spec.ts 123/123, prettier and oxlint clean on the touched files.

Full suite: 4176 passed / 4 failed on the branch, 4172 / 4 on master. The same 4 fail on both (the logger NO_COLOR case, a discovery-service useFactory case, testing-module-builder compile, and the real-ioredis client-redis retry test).

moduleRef.create() builds a new InstanceWrapper for every call and
passes it to the injector as the inquirer. Each dependency then keeps
that wrapper in its dependency tree parent set, so it is never
collected. A transient dependency also keeps the instance it built for
the call in its transient map, under the wrapper's id, so every
transient instance stays alive and lifecycle hooks keep running on it.

create() now detaches its wrapper once it settles: the wrapper leaves
the parent set of each constructor and property dependency, and the
transient instances keyed by its id, nested ones included, are removed.
@kamilmysliwiec
kamilmysliwiec merged commit fa152af into nestjs:master Oct 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants