@fluojs/di 1.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/container.js CHANGED
@@ -1,138 +1,191 @@
1
- import { InvariantError, formatTokenName } from '@fluojs/core';
1
+ import { formatTokenName, InvariantError } from '@fluojs/core';
2
2
  import { getClassDiMetadata } from '@fluojs/core/internal';
3
- import { CircularDependencyError, ContainerResolutionError, DuplicateProviderError, InvalidProviderError, RequestScopeResolutionError, ScopeMismatchError } from './errors.js';
4
- import { Scope, isForwardRef, isOptionalToken } from './types.js';
3
+ import { CircularDependencyError, ContainerResolutionError, DuplicateProviderError, RequestScopeResolutionError, ScopeMismatchError } from './errors.js';
4
+ import { registerMultiContributionResolver } from './multi-contribution-registry.js';
5
+ import { normalizeProvider } from './provider-normalization.js';
6
+ import { isForwardRef, isOptionalToken, Scope } from './types.js';
5
7
 
6
8
  /**
7
- * Public read/write seam for framework-owned testing and tooling that need to
9
+ * Factory provider resolution mode recorded after a factory returns either synchronously or through a promise.
10
+ */
11
+
12
+ /**
13
+ * Controlled cache adoption seam for framework-owned testing and tooling that
14
+ * need synchronous helpers to preserve container-owned singleton disposal.
15
+ */
16
+
17
+ /**
18
+ * Read-only factory resolution diagnostics recorded by container-owned factory
19
+ * instantiation paths.
20
+ */
21
+
22
+ /**
23
+ * Public read-only seam for framework-owned testing and tooling that need to
8
24
  * inspect a container's resolved provider graph without depending on private
9
25
  * field names or structural casts.
10
26
  */
11
27
 
12
- function isClassConstructor(value) {
13
- return typeof value === 'function';
14
- }
15
- function isValueProvider(value) {
16
- return typeof value === 'object' && value !== null && 'useValue' in value;
17
- }
18
- function isFactoryProvider(value) {
19
- return typeof value === 'object' && value !== null && 'useFactory' in value;
20
- }
21
- function isClassProvider(value) {
22
- return typeof value === 'object' && value !== null && 'useClass' in value;
23
- }
24
- function isExistingProvider(value) {
25
- return typeof value === 'object' && value !== null && 'useExisting' in value;
26
- }
27
- function assertProviderToken(provider) {
28
- if (!('provide' in provider) || provider.provide == null) {
29
- throw new InvalidProviderError('Provider object must include a non-null provide token.');
28
+ class ReadonlyMapView {
29
+ #source;
30
+ [Symbol.toStringTag] = 'Map';
31
+ constructor(source) {
32
+ this.#source = source;
33
+ }
34
+ get size() {
35
+ return this.#source.size;
36
+ }
37
+ entries() {
38
+ return this.#source.entries();
39
+ }
40
+ forEach(callbackfn, thisArg) {
41
+ for (const [key, value] of this.#source) {
42
+ callbackfn.call(thisArg, value, key, this);
43
+ }
44
+ }
45
+ get(key) {
46
+ return this.#source.get(key);
47
+ }
48
+ has(key) {
49
+ return this.#source.has(key);
50
+ }
51
+ keys() {
52
+ return this.#source.keys();
53
+ }
54
+ values() {
55
+ return this.#source.values();
56
+ }
57
+ [Symbol.iterator]() {
58
+ return this.entries();
30
59
  }
31
60
  }
32
- function assertProviderStrategy(provider) {
33
- const strategyCount = Number('useValue' in provider) + Number('useFactory' in provider) + Number('useClass' in provider) + Number('useExisting' in provider);
34
- if (strategyCount !== 1) {
35
- throw new InvalidProviderError('Provider object must declare exactly one of useValue, useFactory, useClass, or useExisting.');
61
+ class ReadonlyMultiRegistrationMapView {
62
+ #source;
63
+ [Symbol.toStringTag] = 'Map';
64
+ constructor(source) {
65
+ this.#source = source;
36
66
  }
67
+ get size() {
68
+ return this.#source.size;
69
+ }
70
+ *entries() {
71
+ for (const [token, providers] of this.#source) {
72
+ yield [token, Object.freeze([...providers])];
73
+ }
74
+ }
75
+ forEach(callbackfn, thisArg) {
76
+ for (const [key, value] of this.entries()) {
77
+ callbackfn.call(thisArg, value, key, this);
78
+ }
79
+ }
80
+ get(key) {
81
+ const providers = this.#source.get(key);
82
+ return providers ? Object.freeze([...providers]) : undefined;
83
+ }
84
+ has(key) {
85
+ return this.#source.has(key);
86
+ }
87
+ keys() {
88
+ return this.#source.keys();
89
+ }
90
+ *values() {
91
+ for (const providers of this.#source.values()) {
92
+ yield Object.freeze([...providers]);
93
+ }
94
+ }
95
+ [Symbol.iterator]() {
96
+ return this.entries();
97
+ }
98
+ }
99
+ function isPromiseLike(value) {
100
+ return (typeof value === 'object' || typeof value === 'function') && value !== null && typeof value.then === 'function';
37
101
  }
38
- function assertObjectProvider(provider) {
39
- assertProviderToken(provider);
40
- assertProviderStrategy(provider);
102
+ const pendingResolutionContexts = new WeakMap();
103
+ const pendingResolutionOwners = new WeakMap();
104
+ function trackPendingResolution(promise, activeTokens) {
105
+ const context = pendingResolutionContexts.get(activeTokens) ?? {
106
+ dependencies: new Map(),
107
+ pendingPromises: 0
108
+ };
109
+ context.pendingPromises += 1;
110
+ pendingResolutionContexts.set(activeTokens, context);
111
+ pendingResolutionOwners.set(promise, activeTokens);
41
112
  }
42
- function normalizeInjectToken(token) {
43
- if (token == null) {
44
- throw new InvalidProviderError('Inject token must not be null or undefined. Check that all tokens in @Inject(...) are defined at the point of decoration (forward-reference cycles require forwardRef()).');
113
+ function untrackPendingResolution(promise, activeTokens) {
114
+ const context = pendingResolutionContexts.get(activeTokens);
115
+ pendingResolutionOwners.delete(promise);
116
+ if (!context) {
117
+ return;
118
+ }
119
+ context.pendingPromises -= 1;
120
+ if (context.pendingPromises === 0 && context.dependencies.size === 0) {
121
+ pendingResolutionContexts.delete(activeTokens);
45
122
  }
46
- return token;
47
123
  }
48
- function normalizeProvider(provider) {
49
- if (isClassConstructor(provider)) {
50
- const metadata = getClassDiMetadata(provider);
51
- return {
52
- inject: (metadata?.inject ?? []).map(normalizeInjectToken),
53
- provide: provider,
54
- scope: metadata?.scope ?? Scope.DEFAULT,
55
- type: 'class',
56
- useClass: provider
57
- };
124
+ function findPendingResolutionPath(start, target, visited) {
125
+ if (start === target) {
126
+ return [start];
58
127
  }
59
- if (isValueProvider(provider)) {
60
- assertObjectProvider(provider);
61
- return {
62
- inject: [],
63
- multi: provider.multi,
64
- provide: provider.provide,
65
- scope: Scope.DEFAULT,
66
- type: 'value',
67
- useValue: provider.useValue
68
- };
128
+ if (visited.has(start)) {
129
+ return undefined;
69
130
  }
70
- if (isFactoryProvider(provider)) {
71
- assertObjectProvider(provider);
72
- if (typeof provider.useFactory !== 'function') {
73
- throw new InvalidProviderError('Factory provider useFactory must be a function.', {
74
- token: provider.provide
75
- });
131
+ visited.add(start);
132
+ for (const dependency of pendingResolutionContexts.get(start)?.dependencies.keys() ?? []) {
133
+ const path = findPendingResolutionPath(dependency, target, visited);
134
+ if (path) {
135
+ return [start, ...path];
76
136
  }
77
- const metadata = provider.resolverClass ? getClassDiMetadata(provider.resolverClass) : undefined;
78
- return {
79
- inject: (provider.inject ?? []).map(normalizeInjectToken),
80
- multi: provider.multi,
81
- provide: provider.provide,
82
- scope: provider.scope ?? metadata?.scope ?? Scope.DEFAULT,
83
- type: 'factory',
84
- useFactory: provider.useFactory
85
- };
86
137
  }
87
- if (isClassProvider(provider)) {
88
- assertObjectProvider(provider);
89
- if (typeof provider.useClass !== 'function') {
90
- throw new InvalidProviderError('Class provider useClass must be a constructor.', {
91
- token: provider.provide
92
- });
93
- }
94
- const metadata = getClassDiMetadata(provider.useClass);
95
- return {
96
- inject: (provider.inject ?? metadata?.inject ?? []).map(normalizeInjectToken),
97
- multi: provider.multi,
98
- provide: provider.provide,
99
- scope: provider.scope ?? metadata?.scope ?? Scope.DEFAULT,
100
- type: 'class',
101
- useClass: provider.useClass
102
- };
138
+ return undefined;
139
+ }
140
+ function linkPendingResolution(promise, activeTokens, token) {
141
+ const owner = pendingResolutionOwners.get(promise);
142
+ if (!owner || activeTokens.size === 0) {
143
+ return undefined;
103
144
  }
104
- if (isExistingProvider(provider)) {
105
- assertObjectProvider(provider);
106
- if (provider.useExisting == null) {
107
- throw new InvalidProviderError('Alias provider useExisting must be a non-null token.', {
108
- token: provider.provide
109
- });
145
+ const cyclePath = findPendingResolutionPath(owner, activeTokens, new Set());
146
+ if (cyclePath) {
147
+ throw new CircularDependencyError([...activeTokens, token, ...cyclePath.flatMap(context => [...context])]);
148
+ }
149
+ const context = pendingResolutionContexts.get(activeTokens) ?? {
150
+ dependencies: new Map(),
151
+ pendingPromises: 0
152
+ };
153
+ context.dependencies.set(owner, (context.dependencies.get(owner) ?? 0) + 1);
154
+ pendingResolutionContexts.set(activeTokens, context);
155
+ return () => {
156
+ const dependencyCount = context.dependencies.get(owner);
157
+ if (dependencyCount === undefined) {
158
+ return;
110
159
  }
111
- return {
112
- inject: [],
113
- provide: provider.provide,
114
- scope: Scope.DEFAULT,
115
- type: 'existing',
116
- useExisting: provider.useExisting
117
- };
118
- }
119
- throw new InvalidProviderError('Unsupported provider type.');
160
+ if (dependencyCount === 1) {
161
+ context.dependencies.delete(owner);
162
+ } else {
163
+ context.dependencies.set(owner, dependencyCount - 1);
164
+ }
165
+ if (context.pendingPromises === 0 && context.dependencies.size === 0) {
166
+ pendingResolutionContexts.delete(activeTokens);
167
+ }
168
+ };
120
169
  }
121
170
 
122
171
  /**
123
172
  * Scope-aware dependency injection container for Fluo providers.
124
173
  */
174
+ // allow: SIZE_OK — Container is the package's existing DI lifecycle state machine.
125
175
  export class Container {
176
+ static #childScopeConstruction;
126
177
  registrations = new Map();
127
178
  multiRegistrations = new Map();
128
179
  multiOverriddenTokens = new Set();
129
180
  requestCache;
130
181
  multiRequestCache;
131
182
  multiSingletonCache = new Map();
183
+ materializedCachePromises = [];
184
+ pendingDisposables = [];
132
185
  staleDisposalTasks = new Set();
133
- staleDisposalErrors = [];
134
186
  singletonCache;
135
187
  forwardRefTokenCache = new WeakMap();
188
+ factoryResolutionKinds = new WeakMap();
136
189
  providerLookupPlanCache = new Map();
137
190
  multiProviderPlanCache = new Map();
138
191
  requestScopeVerdictPlanCache = new Map();
@@ -140,12 +193,40 @@ export class Container {
140
193
  childScopes;
141
194
  disposePromise;
142
195
  disposed = false;
143
- trackedByRoot = false;
196
+ trackedByParent = false;
144
197
  graphRevision = 0;
145
- constructor(parent, requestScopeEnabled = false, singletonCache) {
146
- this.parent = parent;
147
- this.requestScopeEnabled = requestScopeEnabled;
148
- this.singletonCache = singletonCache ?? new Map();
198
+ parent;
199
+ requestScopeEnabled;
200
+
201
+ /**
202
+ * Creates a root container that owns its own singleton cache.
203
+ *
204
+ * Child request scopes are package-owned and must be created with
205
+ * {@link Container.createRequestScope}; caller-supplied parent, request-scope,
206
+ * or singleton-cache wiring is rejected.
207
+ *
208
+ * @throws {ContainerResolutionError} When any constructor argument is supplied.
209
+ */
210
+ constructor(...construction) {
211
+ if (construction.length > 0) {
212
+ throw new ContainerResolutionError('Container child-scope construction is package-owned and cannot be invoked directly.', {
213
+ hint: 'Construct root containers with new Container() and create child scopes with container.createRequestScope().'
214
+ });
215
+ }
216
+ const childScope = Container.#childScopeConstruction;
217
+ this.parent = childScope?.parent;
218
+ this.requestScopeEnabled = childScope?.requestScopeEnabled ?? false;
219
+ this.singletonCache = childScope?.singletonCache ?? new Map();
220
+ registerMultiContributionResolver(this, (token, contributionIndex) => this.resolveMultiContribution(token, contributionIndex));
221
+ }
222
+ static #createChildScope(construction) {
223
+ const parentConstruction = Container.#childScopeConstruction;
224
+ Container.#childScopeConstruction = construction;
225
+ try {
226
+ return new Container();
227
+ } finally {
228
+ Container.#childScopeConstruction = parentConstruction;
229
+ }
149
230
  }
150
231
 
151
232
  /**
@@ -199,10 +280,16 @@ export class Container {
199
280
  * set — the whole set is replaced. If you need to preserve other entries, re-register them
200
281
  * together with the replacement in one `override()` call.
201
282
  *
283
+ * **Batch atomicity**: the whole batch is validated before any registration or cache is touched,
284
+ * so a rejected `override()` call leaves every provider, cached instance, and disposal ownership
285
+ * exactly as it was before the call.
286
+ *
202
287
  * @param providers Provider definitions that should replace existing registrations for each token.
203
288
  * @returns The same container instance for fluent override chains.
204
289
  * @throws {ContainerResolutionError} When called after the container was disposed.
290
+ * @throws {ScopeMismatchError} When a request-scope override would introduce a new singleton token.
205
291
  * @throws {InvalidProviderError} When a provider definition is structurally invalid.
292
+ * @throws {DuplicateProviderError} When one token mixes single and multi replacements or repeats a single replacement.
206
293
  */
207
294
  override(...providers) {
208
295
  if (this.isDisposedInHierarchy()) {
@@ -220,6 +307,19 @@ export class Container {
220
307
  }
221
308
  normalizedByToken.set(normalized.provide, [normalized]);
222
309
  }
310
+ if (this.requestScopeEnabled) {
311
+ for (const [token, normalizedProviders] of normalizedByToken) {
312
+ const introducesSingleton = normalizedProviders.some(normalized => normalized.scope === Scope.DEFAULT);
313
+ if (introducesSingleton && !this.has(token)) {
314
+ throw new ScopeMismatchError(`Singleton provider ${formatTokenName(token)} cannot be introduced by override() on a request-scope container.`, {
315
+ token,
316
+ scope: 'singleton',
317
+ hint: 'Register it on the root container before creating the request scope, or register a request/transient provider in the request scope.'
318
+ });
319
+ }
320
+ }
321
+ }
322
+ const plannedOverrides = new Map();
223
323
  for (const [token, normalizedProviders] of normalizedByToken) {
224
324
  const firstProvider = normalizedProviders[0];
225
325
  if (!firstProvider) {
@@ -232,6 +332,17 @@ export class Container {
232
332
  if (!containsMultiProvider && normalizedProviders.length > 1) {
233
333
  throw new DuplicateProviderError(token);
234
334
  }
335
+ plannedOverrides.set(token, {
336
+ containsMultiProvider,
337
+ firstProvider,
338
+ normalizedProviders
339
+ });
340
+ }
341
+ for (const [token, {
342
+ containsMultiProvider,
343
+ firstProvider,
344
+ normalizedProviders
345
+ }] of plannedOverrides) {
235
346
  this.invalidateAffectedCachedEntriesInHierarchy(token);
236
347
  this.registrations.delete(token);
237
348
  this.multiRegistrations.delete(token);
@@ -263,21 +374,61 @@ export class Container {
263
374
  *
264
375
  * This method is the supported introspection seam for packages such as
265
376
  * `@fluojs/testing`; callers should prefer ordinary `has(...)` and
266
- * `resolve(...)` unless they need to preserve container cache ownership while
267
- * implementing a framework-level helper.
377
+ * `resolve(...)` unless they need read-only graph/cache visibility while
378
+ * implementing a framework-level helper. Cache adoption for synchronous
379
+ * helpers goes through `cacheOwner`; the returned maps are not mutable
380
+ * container internals.
268
381
  *
269
- * @returns Provider registrations and resolution caches for this container scope.
382
+ * @returns Read-only provider registrations and resolution caches for this container scope.
270
383
  */
271
384
  inspectResolutionState() {
385
+ const registrations = new Map(this.registrations);
386
+ const multiRegistrations = new Map(Array.from(this.multiRegistrations, ([token, providers]) => [token, Object.freeze([...providers])]));
387
+ const multiSingletonCache = new Map(this.multiSingletonCache);
388
+ const singletonCache = new Map(this.singletonCache);
272
389
  return {
390
+ cacheOwner: this.createCacheOwner(singletonCache, multiSingletonCache),
391
+ factoryResolutionKinds: this.createFactoryResolutionState(),
273
392
  parent: this.parent?.inspectResolutionState(),
274
- registrations: this.registrations,
275
- multiRegistrations: this.multiRegistrations,
276
- multiSingletonCache: this.multiSingletonCache,
393
+ registrations: new ReadonlyMapView(registrations),
394
+ multiRegistrations: new ReadonlyMultiRegistrationMapView(multiRegistrations),
395
+ multiSingletonCache: new ReadonlyMapView(multiSingletonCache),
277
396
  requestScopeEnabled: this.requestScopeEnabled,
278
- singletonCache: this.singletonCache
397
+ singletonCache: new ReadonlyMapView(singletonCache)
279
398
  };
280
399
  }
400
+ createCacheOwner(singletonCacheSnapshot, multiSingletonCacheSnapshot) {
401
+ return Object.freeze({
402
+ deleteMultiSingleton: provider => {
403
+ this.multiSingletonCache.delete(provider);
404
+ multiSingletonCacheSnapshot.delete(provider);
405
+ },
406
+ deleteSingleton: token => {
407
+ this.singletonCache.delete(token);
408
+ singletonCacheSnapshot.delete(token);
409
+ },
410
+ recordFactoryResolution: (provider, kind) => {
411
+ this.root().factoryResolutionKinds.set(provider, kind);
412
+ },
413
+ setMultiSingleton: (provider, promise) => {
414
+ this.multiSingletonCache.set(provider, promise);
415
+ multiSingletonCacheSnapshot.set(provider, promise);
416
+ this.trackCacheMaterialization(promise);
417
+ },
418
+ setSingleton: (token, promise) => {
419
+ this.singletonCache.set(token, promise);
420
+ singletonCacheSnapshot.set(token, promise);
421
+ this.trackCacheMaterialization(promise);
422
+ }
423
+ });
424
+ }
425
+ createFactoryResolutionState() {
426
+ const root = this.root();
427
+ return Object.freeze({
428
+ get: provider => root.factoryResolutionKinds.get(provider),
429
+ has: provider => root.factoryResolutionKinds.has(provider)
430
+ });
431
+ }
281
432
 
282
433
  /**
283
434
  * Returns whether resolving a token may require a request-scope container.
@@ -305,7 +456,11 @@ export class Container {
305
456
  hint: 'Create request scopes before calling container.dispose().'
306
457
  });
307
458
  }
308
- return new Container(this, true, this.root().singletonCache);
459
+ return Container.#createChildScope({
460
+ parent: this,
461
+ requestScopeEnabled: true,
462
+ singletonCache: this.root().singletonCache
463
+ });
309
464
  }
310
465
 
311
466
  /**
@@ -325,42 +480,79 @@ export class Container {
325
480
  hint: 'Ensure all resolves complete before calling container.dispose().'
326
481
  });
327
482
  }
483
+ await this.assertStaleDisposalsSettled();
328
484
  return this.resolveWithChain(token, [], new Set());
329
485
  }
486
+ async resolveMultiContribution(token, contributionIndex) {
487
+ if (this.isDisposedInHierarchy()) {
488
+ throw new ContainerResolutionError('Container has been disposed and can no longer resolve providers.', {
489
+ token,
490
+ hint: 'Ensure all resolves complete before calling container.dispose().'
491
+ });
492
+ }
493
+ await this.assertStaleDisposalsSettled();
494
+ const providers = this.collectMultiProviders(token);
495
+ const provider = providers[contributionIndex];
496
+ if (!provider) {
497
+ throw new ContainerResolutionError(`Multi-provider contribution ${contributionIndex} is not registered for ${formatTokenName(token)}.`, {
498
+ token,
499
+ hint: 'Resolve a contribution index returned by the registered multi-provider order.'
500
+ });
501
+ }
502
+ this.assertSingletonDependencyScopes(provider);
503
+ return await this.withTokenInChain(token, [], new Set(), async (chain, activeTokens) => this.resolveMultiProviderInstance(provider, chain, activeTokens));
504
+ }
330
505
 
331
506
  /**
332
507
  * Disposes cached instances and nested request scopes.
333
508
  *
509
+ * Concurrent callers share the active disposal attempt. After a failed attempt,
510
+ * a later explicit call retries only `onDestroy()` hooks that did not complete;
511
+ * successfully completed hooks are never repeated. Disposal remains terminal
512
+ * for registration, resolution, overrides, and child-scope creation. A directly
513
+ * disposed child owns its remaining retries after that attempt settles, while a
514
+ * parent-started failed attempt remains owned by the parent hierarchy.
515
+ *
334
516
  * @returns A promise that settles after all cached disposable instances are torn down.
335
517
  * @throws {Error} Propagates one or more disposal errors (`AggregateError` when multiple failures occur).
336
518
  */
337
519
  async dispose() {
520
+ await this.disposeWithOrigin('direct');
521
+ }
522
+ async disposeFromParent() {
523
+ await this.disposeWithOrigin('parent');
524
+ }
525
+ async disposeWithOrigin(origin) {
338
526
  if (this.disposePromise) {
339
527
  await this.disposePromise;
340
528
  return;
341
529
  }
342
530
  this.disposed = true;
343
531
  this.advanceGraphRevision();
344
- this.disposePromise = this.disposeAll();
532
+ const activeDispose = this.disposeAll(origin);
533
+ this.disposePromise = activeDispose;
345
534
  try {
346
- await this.disposePromise;
535
+ await activeDispose;
536
+ if (this.disposePromise === activeDispose && this.hasRetainedStaleDisposalTasksInSubtree()) {
537
+ this.disposePromise = undefined;
538
+ }
347
539
  } catch (error) {
348
540
  this.disposePromise = undefined;
349
541
  throw error;
350
542
  }
351
543
  }
352
- async disposeAll() {
544
+ async disposeAll(origin) {
353
545
  const errors = [];
546
+ let completed = false;
354
547
  try {
355
- // Dispose all live request-scope children first (root only)
356
- if (!this.parent && this.childScopes && this.childScopes.size > 0) {
357
- const childResults = await Promise.allSettled(Array.from(this.childScopes).map(child => child.dispose()));
548
+ // Dispose all live request-scope children before tearing down this scope's cache.
549
+ if (this.childScopes && this.childScopes.size > 0) {
550
+ const childResults = await Promise.allSettled(Array.from(this.childScopes).map(child => child.disposeFromParent()));
358
551
  for (const result of childResults) {
359
552
  if (result.status === 'rejected') {
360
553
  this.collectDisposalError(result.reason, errors);
361
554
  }
362
555
  }
363
- this.childScopes.clear();
364
556
  }
365
557
  try {
366
558
  await this.disposeCache(this.disposalCacheEntries());
@@ -368,10 +560,15 @@ export class Container {
368
560
  this.collectDisposalError(error, errors);
369
561
  }
370
562
  this.throwDisposalErrors(errors);
563
+ completed = true;
371
564
  } finally {
372
- if (this.parent && this.trackedByRoot) {
373
- this.root().childScopes?.delete(this);
374
- this.trackedByRoot = false;
565
+ const retainsStaleRetries = this.hasRetainedStaleDisposalTasksInSubtree();
566
+ if ((origin === 'direct' || completed && !retainsStaleRetries) && this.parent && this.trackedByParent) {
567
+ if (origin === 'direct') {
568
+ this.releaseNonOwnerStaleTaskObserversInSubtree();
569
+ }
570
+ this.parent.childScopes?.delete(this);
571
+ this.trackedByParent = false;
375
572
  }
376
573
  }
377
574
  }
@@ -503,7 +700,12 @@ export class Container {
503
700
  }
504
701
  const cachedInstance = this.getCachedScopedOrSingletonInstance(provider);
505
702
  if (cachedInstance) {
506
- return await cachedInstance;
703
+ const releasePendingResolution = linkPendingResolution(cachedInstance, activeTokens, token);
704
+ try {
705
+ return await cachedInstance;
706
+ } finally {
707
+ releasePendingResolution?.();
708
+ }
507
709
  }
508
710
  return await this.withTokenInChain(token, chain, activeTokens, async (c, at) => this.resolveScopedOrSingletonInstance(provider, c, at));
509
711
  }
@@ -543,16 +745,29 @@ export class Container {
543
745
  if (provider.scope === 'transient') {
544
746
  return await this.instantiate(provider, chain, activeTokens);
545
747
  }
546
- if (this.shouldResolveMultiProviderFromRoot(provider)) {
547
- return await this.root().resolveMultiProviderInstance(provider, chain, activeTokens);
748
+ const cacheOwner = this.cacheOwnerFor(provider);
749
+ if (cacheOwner !== this) {
750
+ return await cacheOwner.resolveMultiProviderInstance(provider, chain, activeTokens);
548
751
  }
549
752
  const cache = this.multiCacheFor(provider);
550
- if (!cache.has(provider)) {
551
- const promise = this.instantiate(provider, chain, activeTokens);
552
- cache.set(provider, promise);
553
- promise.catch(() => cache.delete(provider));
554
- }
555
- return await cache.get(provider);
753
+ const cachedInstance = cache.get(provider);
754
+ if (cachedInstance) {
755
+ const releasePendingResolution = linkPendingResolution(cachedInstance, activeTokens, provider.provide);
756
+ return releasePendingResolution ? cachedInstance.finally(releasePendingResolution) : cachedInstance;
757
+ }
758
+ let promise;
759
+ promise = this.instantiate(provider, chain, activeTokens).then(value => {
760
+ untrackPendingResolution(promise, activeTokens);
761
+ return value;
762
+ }, error => {
763
+ cache.delete(provider);
764
+ untrackPendingResolution(promise, activeTokens);
765
+ throw error;
766
+ });
767
+ trackPendingResolution(promise, activeTokens);
768
+ cache.set(provider, promise);
769
+ this.trackCacheMaterialization(promise);
770
+ return promise;
556
771
  }
557
772
  resolveExistingProviderTarget(provider) {
558
773
  if (provider.type !== 'existing') {
@@ -561,33 +776,48 @@ export class Container {
561
776
  return provider.useExisting;
562
777
  }
563
778
  async resolveScopedOrSingletonInstance(provider, chain, activeTokens) {
564
- if (this.shouldResolveFromRoot(provider)) {
565
- return await this.root().resolveScopedOrSingletonInstance(provider, chain, activeTokens);
779
+ const cacheOwner = this.cacheOwnerFor(provider);
780
+ if (cacheOwner !== this) {
781
+ return await cacheOwner.resolveScopedOrSingletonInstance(provider, chain, activeTokens);
566
782
  }
567
783
  const cache = this.cacheFor(provider);
568
- if (!cache.has(provider.provide)) {
569
- const promise = this.instantiate(provider, chain, activeTokens).catch(error => {
570
- cache.delete(provider.provide);
571
- throw error;
572
- });
573
- cache.set(provider.provide, promise);
574
- }
575
- return cache.get(provider.provide);
784
+ const cachedInstance = cache.get(provider.provide);
785
+ if (cachedInstance) {
786
+ const releasePendingResolution = linkPendingResolution(cachedInstance, activeTokens, provider.provide);
787
+ return releasePendingResolution ? cachedInstance.finally(releasePendingResolution) : cachedInstance;
788
+ }
789
+ let promise;
790
+ promise = this.instantiate(provider, chain, activeTokens).then(value => {
791
+ untrackPendingResolution(promise, activeTokens);
792
+ return value;
793
+ }, error => {
794
+ cache.delete(provider.provide);
795
+ untrackPendingResolution(promise, activeTokens);
796
+ throw error;
797
+ });
798
+ trackPendingResolution(promise, activeTokens);
799
+ cache.set(provider.provide, promise);
800
+ this.trackCacheMaterialization(promise);
801
+ return promise;
576
802
  }
577
803
  getCachedScopedOrSingletonInstance(provider) {
578
804
  if (provider.scope !== Scope.DEFAULT) {
579
805
  return undefined;
580
806
  }
581
- if (this.shouldResolveFromRoot(provider)) {
582
- return this.root().getCachedScopedOrSingletonInstance(provider);
807
+ const cacheOwner = this.cacheOwnerFor(provider);
808
+ if (cacheOwner !== this) {
809
+ return cacheOwner.getCachedScopedOrSingletonInstance(provider);
583
810
  }
584
811
  return this.cacheFor(provider).get(provider.provide);
585
812
  }
586
- shouldResolveFromRoot(provider) {
587
- return provider.scope === Scope.DEFAULT && this.requestScopeEnabled && !this.registrations.has(provider.provide);
588
- }
589
- shouldResolveMultiProviderFromRoot(provider) {
590
- return provider.scope === Scope.DEFAULT && this.requestScopeEnabled && !this.hasLocalMultiProvider(provider);
813
+ cacheOwnerFor(provider) {
814
+ if (provider.scope !== Scope.DEFAULT || !this.requestScopeEnabled) {
815
+ return this;
816
+ }
817
+ if (this.registrations.get(provider.provide) === provider || this.hasLocalMultiProvider(provider)) {
818
+ return this;
819
+ }
820
+ return this.parent?.cacheOwnerFor(provider) ?? this;
591
821
  }
592
822
  async resolveDepToken(depEntry, chain, activeTokens) {
593
823
  if (isOptionalToken(depEntry)) {
@@ -617,13 +847,13 @@ export class Container {
617
847
  return this.parent ? this.parent.root() : this;
618
848
  }
619
849
  ensureTrackedRequestScope() {
620
- if (!this.requestScopeEnabled || !this.parent || this.trackedByRoot) {
850
+ if (!this.requestScopeEnabled || !this.parent || this.trackedByParent) {
621
851
  return;
622
852
  }
623
- const root = this.root();
624
- root.childScopes ??= new Set();
625
- root.childScopes.add(this);
626
- this.trackedByRoot = true;
853
+ this.parent.ensureTrackedRequestScope();
854
+ this.parent.childScopes ??= new Set();
855
+ this.parent.childScopes.add(this);
856
+ this.trackedByParent = true;
627
857
  }
628
858
  requestCacheForWrite() {
629
859
  this.ensureTrackedRequestScope();
@@ -706,12 +936,22 @@ export class Container {
706
936
  return entries;
707
937
  }
708
938
  async disposeCache(entries) {
709
- await this.waitForStaleDisposalTasks();
939
+ const errors = [];
940
+ const retryableStaleTasks = this.retainedStaleDisposalTasks();
941
+ const attemptedStaleInstances = new Set();
942
+ try {
943
+ await this.assertStaleDisposalsSettled();
944
+ } catch (error) {
945
+ this.collectDisposalError(error, errors);
946
+ }
947
+ errors.push(...(await this.retryFailedStaleDisposals(retryableStaleTasks, attemptedStaleInstances)));
710
948
  const {
711
- disposables,
712
- errors
949
+ disposables: materializedDisposables,
950
+ errors: resolutionErrors
713
951
  } = await this.collectDisposableInstances(entries);
714
- errors.push(...this.staleDisposalErrors.splice(0, this.staleDisposalErrors.length));
952
+ const disposalCandidates = this.pendingDisposables.length > 0 ? this.pendingDisposables : materializedDisposables;
953
+ const disposables = disposalCandidates.filter(instance => !attemptedStaleInstances.has(instance));
954
+ errors.push(...resolutionErrors);
715
955
  errors.push(...(await this.disposeInstancesInReverseOrder(disposables)));
716
956
  this.clearDisposalCaches();
717
957
  this.throwDisposalErrors(errors);
@@ -720,13 +960,18 @@ export class Container {
720
960
  const disposables = [];
721
961
  const seenInstances = new Set();
722
962
  const errors = [];
963
+ const activePromises = new Set(entries.map(([, promise]) => promise));
723
964
  const settled = await Promise.allSettled(entries.map(([, p]) => p));
724
965
  for (const result of settled) {
725
966
  if (result.status === 'rejected') {
726
967
  errors.push(result.reason);
968
+ }
969
+ }
970
+ for (const promise of this.materializedCachePromises) {
971
+ if (!activePromises.has(promise)) {
727
972
  continue;
728
973
  }
729
- const instance = result.value;
974
+ const instance = await promise;
730
975
  if (this.isDisposable(instance) && !seenInstances.has(instance)) {
731
976
  seenInstances.add(instance);
732
977
  disposables.push(instance);
@@ -739,16 +984,20 @@ export class Container {
739
984
  }
740
985
  async disposeInstancesInReverseOrder(disposables) {
741
986
  const errors = [];
987
+ const pendingDisposables = [];
742
988
  for (const instance of [...disposables].reverse()) {
743
989
  try {
744
990
  await instance.onDestroy();
745
991
  } catch (error) {
746
992
  errors.push(error);
993
+ pendingDisposables.unshift(instance);
747
994
  }
748
995
  }
996
+ this.pendingDisposables.splice(0, this.pendingDisposables.length, ...pendingDisposables);
749
997
  return errors;
750
998
  }
751
999
  clearDisposalCaches() {
1000
+ this.materializedCachePromises.length = 0;
752
1001
  if (this.parent) {
753
1002
  this.requestCache?.clear();
754
1003
  this.multiRequestCache?.clear();
@@ -759,6 +1008,9 @@ export class Container {
759
1008
  this.multiSingletonCache.clear();
760
1009
  this.clearResolutionPlanCaches();
761
1010
  }
1011
+ trackCacheMaterialization(promise) {
1012
+ void promise.then(() => this.materializedCachePromises.push(promise), () => undefined);
1013
+ }
762
1014
  currentLineageRevision() {
763
1015
  const parentRevision = this.parent?.currentLineageRevision();
764
1016
  return parentRevision ? `${parentRevision}/${this.graphRevision}` : String(this.graphRevision);
@@ -787,26 +1039,129 @@ export class Container {
787
1039
  this.requestScopeVerdictPlanCache.clear();
788
1040
  this.effectiveProviderPlanCache.clear();
789
1041
  }
790
- async waitForStaleDisposalTasks() {
791
- while (this.staleDisposalTasks.size > 0) {
792
- await Promise.all(Array.from(this.staleDisposalTasks));
1042
+ async assertStaleDisposalsSettled() {
1043
+ const errors = [];
1044
+ const settled = new Set();
1045
+ while (true) {
1046
+ const tasks = Array.from(this.staleDisposalTasks).filter(task => !settled.has(task));
1047
+ if (tasks.length === 0) {
1048
+ break;
1049
+ }
1050
+ await Promise.all(tasks.map(task => task.promise));
1051
+ for (const task of tasks) {
1052
+ settled.add(task);
1053
+ if (!task.failed) {
1054
+ this.staleDisposalTasks.delete(task);
1055
+ continue;
1056
+ }
1057
+
1058
+ // A rejected materialization has no hook to retry. Deliver its error
1059
+ // once, then release it from every observer's stale-task ledger.
1060
+ if (!task.errorConsumed) {
1061
+ task.errorConsumed = true;
1062
+ errors.push(task.error);
1063
+ }
1064
+ if (!task.retryInstance) {
1065
+ for (const observer of task.observers) {
1066
+ observer.staleDisposalTasks.delete(task);
1067
+ }
1068
+ }
1069
+ }
793
1070
  }
1071
+ this.throwDisposalErrors(errors);
1072
+ }
1073
+ retainedStaleDisposalTasks() {
1074
+ // Only the scheduling container retries a stale hook, and only when the
1075
+ // failure was already delivered to an earlier caller. A failure first
1076
+ // surfaced by this attempt is reported, not retried within the same pass.
1077
+ return Array.from(this.staleDisposalTasks).filter(task => task.retryOwner === this && task.failed && task.errorConsumed);
1078
+ }
1079
+ hasRetainedStaleDisposalTasksInSubtree() {
1080
+ return this.retainedStaleDisposalTasks().length > 0 || Array.from(this.childScopes ?? []).some(childScope => childScope.hasRetainedStaleDisposalTasksInSubtree());
794
1081
  }
795
- scheduleStaleDisposal(instancePromise) {
796
- let task;
797
- task = (async () => {
1082
+ releaseNonOwnerStaleTaskObservers() {
1083
+ for (const task of this.staleDisposalTasks) {
1084
+ if (task.retryOwner !== this) {
1085
+ continue;
1086
+ }
1087
+ for (const observer of task.observers) {
1088
+ if (observer !== this) {
1089
+ observer.staleDisposalTasks.delete(task);
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ releaseNonOwnerStaleTaskObserversInSubtree() {
1095
+ this.releaseNonOwnerStaleTaskObservers();
1096
+ for (const childScope of this.childScopes ?? []) {
1097
+ childScope.releaseNonOwnerStaleTaskObserversInSubtree();
1098
+ }
1099
+ }
1100
+ async retryFailedStaleDisposals(tasks, attemptedInstances) {
1101
+ const errors = [];
1102
+ for (const task of tasks) {
1103
+ const instance = task.retryInstance;
1104
+ if (!task.failed || !instance) {
1105
+ continue;
1106
+ }
1107
+ attemptedInstances.add(instance);
798
1108
  try {
799
- const instance = await instancePromise;
800
- if (this.isDisposable(instance)) {
801
- await instance.onDestroy();
1109
+ await instance.onDestroy();
1110
+ task.failed = false;
1111
+ task.retryInstance = undefined;
1112
+ for (const observer of task.observers) {
1113
+ observer.staleDisposalTasks.delete(task);
802
1114
  }
803
1115
  } catch (error) {
804
- this.staleDisposalErrors.push(error);
1116
+ task.error = error;
1117
+ task.errorConsumed = true;
1118
+ errors.push(error);
1119
+ }
1120
+ }
1121
+ return errors;
1122
+ }
1123
+ scheduleStaleDisposal(instancePromise, staleDisposalOwner) {
1124
+ const observers = staleDisposalOwner === this ? [this] : [this, staleDisposalOwner];
1125
+ const task = {
1126
+ error: undefined,
1127
+ errorConsumed: false,
1128
+ failed: false,
1129
+ observers,
1130
+ promise: Promise.resolve(),
1131
+ retryInstance: undefined,
1132
+ retryOwner: this
1133
+ };
1134
+ task.promise = (async () => {
1135
+ let instance;
1136
+ try {
1137
+ instance = await instancePromise;
1138
+ } catch (error) {
1139
+ task.error = error;
1140
+ task.failed = true;
1141
+ return;
1142
+ }
1143
+ if (!this.isDisposable(instance)) {
1144
+ return;
1145
+ }
1146
+ try {
1147
+ await instance.onDestroy();
1148
+ } catch (error) {
1149
+ task.error = error;
1150
+ task.failed = true;
1151
+ task.retryInstance = instance;
805
1152
  }
806
1153
  })().finally(() => {
807
- this.staleDisposalTasks.delete(task);
1154
+ const retainedMaterializations = this.materializedCachePromises.filter(promise => promise !== instancePromise);
1155
+ this.materializedCachePromises.splice(0, this.materializedCachePromises.length, ...retainedMaterializations);
1156
+ if (!task.failed) {
1157
+ for (const observer of observers) {
1158
+ observer.staleDisposalTasks.delete(task);
1159
+ }
1160
+ }
808
1161
  });
809
- this.staleDisposalTasks.add(task);
1162
+ for (const observer of observers) {
1163
+ observer.staleDisposalTasks.add(task);
1164
+ }
810
1165
  }
811
1166
  throwDisposalErrors(errors) {
812
1167
  if (errors.length === 1) {
@@ -839,7 +1194,9 @@ export class Container {
839
1194
  throw new InvariantError('Factory provider is missing useFactory.');
840
1195
  }
841
1196
  const deps = await this.resolveProviderDeps(provider, chain, activeTokens);
842
- return provider.useFactory(...deps);
1197
+ const value = provider.useFactory(...deps);
1198
+ this.root().factoryResolutionKinds.set(provider, isPromiseLike(value) ? 'async' : 'sync');
1199
+ return value;
843
1200
  }
844
1201
  case 'class':
845
1202
  {
@@ -885,6 +1242,10 @@ export class Container {
885
1242
  }
886
1243
  visited.add(token);
887
1244
  try {
1245
+ const multiRequestScopedToken = this.findRequestScopedMultiContribution(token, visited);
1246
+ if (multiRequestScopedToken) {
1247
+ return multiRequestScopedToken;
1248
+ }
888
1249
  const provider = this.resolveEffectiveProvider(token);
889
1250
  if (provider) {
890
1251
  if (provider.scope === Scope.REQUEST) {
@@ -904,6 +1265,28 @@ export class Container {
904
1265
  visited.delete(token);
905
1266
  }
906
1267
  }
1268
+ findRequestScopedMultiContribution(token, visited) {
1269
+ const aliasChain = new Set();
1270
+ let currentToken = token;
1271
+ while (!aliasChain.has(currentToken)) {
1272
+ aliasChain.add(currentToken);
1273
+ for (const multiProvider of this.collectMultiProviders(currentToken)) {
1274
+ if (multiProvider.scope === Scope.REQUEST) {
1275
+ return multiProvider.provide;
1276
+ }
1277
+ const requestScopedToken = multiProvider.type === 'existing' && multiProvider.useExisting !== undefined ? this.findRequestScopedDependencyToken(multiProvider.useExisting, visited) : this.findRequestScopedDependency(multiProvider.inject, visited);
1278
+ if (requestScopedToken) {
1279
+ return requestScopedToken;
1280
+ }
1281
+ }
1282
+ const aliasProvider = this.lookupProvider(currentToken);
1283
+ if (aliasProvider?.type !== 'existing' || aliasProvider.useExisting === undefined) {
1284
+ return undefined;
1285
+ }
1286
+ currentToken = aliasProvider.useExisting;
1287
+ }
1288
+ return undefined;
1289
+ }
907
1290
  resolveEffectiveProvider(token, visited = new Set(), chain = []) {
908
1291
  const cacheable = visited.size === 0 && chain.length === 0;
909
1292
  if (cacheable) {
@@ -959,34 +1342,18 @@ export class Container {
959
1342
  }
960
1343
  return deps;
961
1344
  }
962
- invalidateAffectedCachedEntriesInHierarchy(token) {
963
- this.invalidateAffectedCachedEntries(token);
964
- const childScopes = this.root().childScopes;
965
- if (!childScopes) {
966
- return;
967
- }
968
- for (const childScope of childScopes) {
969
- if (this.isAncestorOf(childScope)) {
970
- childScope.invalidateAffectedCachedEntries(token);
971
- }
972
- }
973
- }
974
- isAncestorOf(container) {
975
- let current = container.parent;
976
- while (current) {
977
- if (current === this) {
978
- return true;
979
- }
980
- current = current.parent;
1345
+ invalidateAffectedCachedEntriesInHierarchy(token, staleDisposalOwner = this) {
1346
+ this.invalidateAffectedCachedEntries(token, staleDisposalOwner);
1347
+ for (const childScope of this.childScopes ?? []) {
1348
+ childScope.invalidateAffectedCachedEntriesInHierarchy(token, staleDisposalOwner);
981
1349
  }
982
- return false;
983
1350
  }
984
- invalidateAffectedCachedEntries(token) {
1351
+ invalidateAffectedCachedEntries(token, staleDisposalOwner) {
985
1352
  for (const [cachedToken, cached] of this.requestCache?.entries() ?? []) {
986
1353
  if (!this.shouldInvalidateCachedToken(cachedToken, token)) {
987
1354
  continue;
988
1355
  }
989
- this.scheduleStaleDisposal(cached);
1356
+ this.scheduleStaleDisposal(cached, staleDisposalOwner);
990
1357
  this.requestCache?.delete(cachedToken);
991
1358
  }
992
1359
  if (!this.parent) {
@@ -994,7 +1361,7 @@ export class Container {
994
1361
  if (!this.shouldInvalidateCachedToken(cachedToken, token)) {
995
1362
  continue;
996
1363
  }
997
- this.scheduleStaleDisposal(cached);
1364
+ this.scheduleStaleDisposal(cached, staleDisposalOwner);
998
1365
  this.singletonCache.delete(cachedToken);
999
1366
  }
1000
1367
  }
@@ -1003,7 +1370,7 @@ export class Container {
1003
1370
  if (!this.shouldInvalidateCachedProvider(provider, token)) {
1004
1371
  continue;
1005
1372
  }
1006
- this.scheduleStaleDisposal(cached);
1373
+ this.scheduleStaleDisposal(cached, staleDisposalOwner);
1007
1374
  this.multiSingletonCache.delete(provider);
1008
1375
  }
1009
1376
  }
@@ -1015,7 +1382,7 @@ export class Container {
1015
1382
  if (!this.shouldInvalidateCachedProvider(provider, token)) {
1016
1383
  continue;
1017
1384
  }
1018
- this.scheduleStaleDisposal(cached);
1385
+ this.scheduleStaleDisposal(cached, staleDisposalOwner);
1019
1386
  multiRequestCache.delete(provider);
1020
1387
  }
1021
1388
  }