@codefast/di 0.5.0-canary.8 → 0.5.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +387 -0
  2. package/README.md +3 -1
  3. package/dist/binding.d.ts +41 -22
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +11 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/container/binding-builders.d.ts.map +1 -1
  8. package/dist/container/binding-builders.js +8 -5
  9. package/dist/container/binding-builders.js.map +1 -1
  10. package/dist/container/container.d.ts.map +1 -1
  11. package/dist/container/container.js +67 -110
  12. package/dist/container/container.js.map +1 -1
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +13 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/introspection/inspector.d.ts +0 -1
  22. package/dist/introspection/inspector.d.ts.map +1 -1
  23. package/dist/introspection/inspector.js +2 -7
  24. package/dist/introspection/inspector.js.map +1 -1
  25. package/dist/registry.d.ts.map +1 -1
  26. package/dist/registry.js +46 -33
  27. package/dist/registry.js.map +1 -1
  28. package/dist/resolution/activation-need.d.ts +4 -2
  29. package/dist/resolution/activation-need.d.ts.map +1 -1
  30. package/dist/resolution/activation-need.js +15 -11
  31. package/dist/resolution/activation-need.js.map +1 -1
  32. package/dist/resolution/binding-lookup-cache.d.ts +1 -1
  33. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -1
  34. package/dist/resolution/binding-lookup-cache.js +18 -2
  35. package/dist/resolution/binding-lookup-cache.js.map +1 -1
  36. package/dist/resolution/binding-scope.d.ts +5 -2
  37. package/dist/resolution/binding-scope.d.ts.map +1 -1
  38. package/dist/resolution/binding-scope.js +6 -17
  39. package/dist/resolution/binding-scope.js.map +1 -1
  40. package/dist/resolution/binding-select.d.ts +8 -1
  41. package/dist/resolution/binding-select.d.ts.map +1 -1
  42. package/dist/resolution/binding-select.js +13 -32
  43. package/dist/resolution/binding-select.js.map +1 -1
  44. package/dist/resolution/diagnostics.d.ts +2 -2
  45. package/dist/resolution/diagnostics.d.ts.map +1 -1
  46. package/dist/resolution/environment.d.ts +47 -21
  47. package/dist/resolution/environment.d.ts.map +1 -1
  48. package/dist/resolution/environment.js +134 -31
  49. package/dist/resolution/environment.js.map +1 -1
  50. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  51. package/dist/resolution/instantiation-plan.js +1 -1
  52. package/dist/resolution/instantiation-plan.js.map +1 -1
  53. package/dist/resolution/lifecycle.d.ts.map +1 -1
  54. package/dist/resolution/lifecycle.js +46 -53
  55. package/dist/resolution/lifecycle.js.map +1 -1
  56. package/dist/resolution/resolution-path.d.ts +82 -6
  57. package/dist/resolution/resolution-path.d.ts.map +1 -1
  58. package/dist/resolution/resolution-path.js +66 -8
  59. package/dist/resolution/resolution-path.js.map +1 -1
  60. package/dist/resolution/resolve-options.d.ts +41 -4
  61. package/dist/resolution/resolve-options.d.ts.map +1 -1
  62. package/dist/resolution/resolve-options.js +25 -1
  63. package/dist/resolution/resolve-options.js.map +1 -1
  64. package/dist/resolution/resolver.d.ts +29 -11
  65. package/dist/resolution/resolver.d.ts.map +1 -1
  66. package/dist/resolution/resolver.js +417 -564
  67. package/dist/resolution/resolver.js.map +1 -1
  68. package/dist/resolution/scope.d.ts +4 -0
  69. package/dist/resolution/scope.d.ts.map +1 -1
  70. package/dist/resolution/scope.js +8 -0
  71. package/dist/resolution/scope.js.map +1 -1
  72. package/package.json +5 -11
  73. package/src/binding.ts +46 -23
  74. package/src/container/binding-builders.ts +8 -5
  75. package/src/container/container.ts +72 -119
  76. package/src/errors.ts +17 -0
  77. package/src/index.ts +3 -1
  78. package/src/introspection/inspector.ts +2 -8
  79. package/src/registry.ts +52 -35
  80. package/src/resolution/activation-need.ts +18 -14
  81. package/src/resolution/binding-lookup-cache.ts +18 -2
  82. package/src/resolution/binding-scope.ts +6 -17
  83. package/src/resolution/binding-select.ts +14 -35
  84. package/src/resolution/diagnostics.ts +2 -2
  85. package/src/resolution/environment.ts +181 -35
  86. package/src/resolution/instantiation-plan.ts +5 -5
  87. package/src/resolution/lifecycle.ts +55 -53
  88. package/src/resolution/resolution-path.ts +119 -25
  89. package/src/resolution/resolve-options.ts +51 -4
  90. package/src/resolution/resolver.ts +582 -785
  91. package/src/resolution/scope.ts +10 -0
@@ -2,25 +2,14 @@ import type { Binding } from "#/binding";
2
2
  import type { BindingScope } from "#/types";
3
3
 
4
4
  /**
5
- * Runtime scope used for validation and introspection. Alias bindings are treated as transient
6
- * because they defer scope to the aliased target at resolve time.
5
+ * The scope a binding resolves under.
6
+ *
7
+ * @remarks Every kind declares one — an alias declares `transient`, since it defers scoping to the
8
+ * binding it points at — so this is a field read, kept as a named function because it is the
9
+ * vocabulary validation and introspection speak.
7
10
  *
8
11
  * @since 0.3.16-canary.0
9
12
  */
10
13
  export function effectiveBindingScope(binding: Binding): BindingScope {
11
- switch (binding.kind) {
12
- case "alias":
13
- return "transient";
14
- case "class":
15
- case "constant":
16
- case "dynamic":
17
- case "dynamic-async":
18
- case "resolved":
19
- case "resolved-async":
20
- return binding.scope;
21
- default: {
22
- const exhaustive: never = binding;
23
- return exhaustive;
24
- }
25
- }
14
+ return binding.scope;
26
15
  }
@@ -1,4 +1,4 @@
1
- import type { Binding } from "#/binding";
1
+ import type { Binding, BindingSlot } from "#/binding";
2
2
  import { AmbiguousBindingError } from "#/errors";
3
3
  import type { BindingTag, ConstraintContext, ResolveOptions } from "#/types";
4
4
 
@@ -62,50 +62,29 @@ function filterBindings(
62
62
  ctx: ConstraintContext,
63
63
  selectionMode: "single" | "all" = "single",
64
64
  ): Array<Binding> {
65
- if (options === undefined) {
66
- const resultWithoutOptions: Array<Binding> = [];
67
- if (selectionMode === "all") {
68
- for (const binding of bindings) {
69
- if (matchesPredicate(binding, ctx)) {
70
- resultWithoutOptions.push(binding);
71
- }
72
- }
73
- } else {
74
- for (const binding of bindings) {
75
- const slot = binding.slot;
76
- if (slot.name === undefined && slot.tags.length === 0 && matchesPredicate(binding, ctx)) {
77
- resultWithoutOptions.push(binding);
78
- }
79
- }
80
- }
81
- return resultWithoutOptions;
82
- }
83
-
65
+ // `resolveAll` with no slot criterion takes every binding; `resolve` always matches the slot,
66
+ // where an absent criterion means "the default slot".
67
+ const requiresSlotMatch = selectionMode === "single" || (options !== undefined && hasSlotCriterion(options));
84
68
  const result: Array<Binding> = [];
85
69
  for (const binding of bindings) {
86
- const slotMatched =
87
- selectionMode === "all" ? matchesSlotForResolveAll(binding, options) : matchesSlot(binding, options);
88
- if (slotMatched && matchesPredicate(binding, ctx)) {
70
+ if ((!requiresSlotMatch || matchesSlot(binding.slot, options)) && matchesPredicate(binding, ctx)) {
89
71
  result.push(binding);
90
72
  }
91
73
  }
92
74
  return result;
93
75
  }
94
76
 
95
- function matchesSlotForResolveAll(binding: Binding, options: ResolveOptions | undefined): boolean {
96
- const hasExplicitSlotFilter =
97
- options !== undefined &&
98
- (options.name !== undefined ||
99
- (options.tags !== undefined && options.tags.length > 0) ||
100
- options.tag !== undefined);
101
- if (!hasExplicitSlotFilter) {
102
- return true;
103
- }
104
- return matchesSlot(binding, options);
77
+ function hasSlotCriterion(options: ResolveOptions): boolean {
78
+ return options.name !== undefined || options.tag !== undefined || (options.tags?.length ?? 0) > 0;
105
79
  }
106
80
 
107
- function matchesSlot(binding: Binding, options: ResolveOptions | undefined): boolean {
108
- const slot = binding.slot;
81
+ /**
82
+ * Whether a binding's slot satisfies a request: names must be equal, and every tag the slot
83
+ * declares must be among the tags requested (SPEC §6.9).
84
+ *
85
+ * @since 0.5.0-canary.9
86
+ */
87
+ export function matchesSlot(slot: BindingSlot, options: ResolveOptions | undefined): boolean {
109
88
  const requestedName = options?.name;
110
89
  const requestedTags = options?.tags;
111
90
  const singleRequestedTag = options?.tag;
@@ -27,8 +27,8 @@ export interface ResolutionDiagnostics {
27
27
  readonly compiledPlanCount: number;
28
28
  /** Contexts held by the depth-indexed sync pool. */
29
29
  readonly syncContextPoolSize: number;
30
- /** Contexts returned to the async chain pool and available for reuse. */
31
- readonly asyncContextPoolSize: number;
30
+ /** Scoped instances currently cached by this container's scope manager. */
31
+ readonly scopedInstanceCount: number;
32
32
  /** Deferred collaborators this container has had to build. */
33
33
  readonly builtSubsystems: ReadonlyArray<string>;
34
34
  }
@@ -1,4 +1,6 @@
1
1
  import type { Container } from "#/container/container";
2
+ import type { BranchDepth, OwnedBranchPath, OwnedBranchStack } from "#/resolution/resolution-path";
3
+ import { UNOWNED_BRANCH } from "#/resolution/resolution-path";
2
4
  import type { Token } from "#/token";
3
5
  import type {
4
6
  BindingIdentifier,
@@ -56,8 +58,10 @@ export interface ResolverCallbacks {
56
58
  token: Token<Value> | Constructor<Value>,
57
59
  resolutionPath: Array<string>,
58
60
  resolutionStack: Array<ResolutionFrame>,
59
- callerContext?: DefaultResolutionContext,
61
+ branchDepth: BranchDepth,
60
62
  ): Promise<Value>;
63
+ /** Not one of the eight `Value`-naming entry points: its caller is, and casts once. */
64
+ resolveAsyncFromCascade(token: Token<unknown> | Constructor): Promise<unknown>;
61
65
  resolveAsync<const Value>(
62
66
  token: Token<Value> | Constructor<Value>,
63
67
  options: ResolveOptions | undefined,
@@ -106,7 +110,6 @@ export class DefaultResolutionContext implements ResolutionContext {
106
110
  currentOptions: ResolveOptions | undefined,
107
111
  ) {
108
112
  this.#resolver = resolver;
109
- this.owner = resolver;
110
113
  this.#resolutionPath = resolutionPath;
111
114
  this.#resolutionStack = resolutionStack;
112
115
  this.#currentOptions = currentOptions;
@@ -114,30 +117,6 @@ export class DefaultResolutionContext implements ResolutionContext {
114
117
 
115
118
  #graph: ConstraintContext | undefined;
116
119
 
117
- /**
118
- * The unwind callback shared by every level of the async chain this context serves.
119
- *
120
- * @remarks A plain field, not a lazy accessor — a getter taking a factory would allocate that
121
- * factory on every level, which is the allocation this exists to avoid.
122
- */
123
- chainSettle: (() => void) | undefined;
124
-
125
- /**
126
- * How many levels of the async chain are still in flight on this context.
127
- *
128
- * @remarks The chain's first level acquires the context and every level increments; the last
129
- * one to settle returns it to the resolver's pool. Counting here rather than on the resolver
130
- * is what lets two concurrent chains run without a shared counter to get wrong.
131
- */
132
- chainLevels = 0;
133
-
134
- /**
135
- * The resolver this context speaks to — an inner async level checks it before reusing this.
136
- *
137
- * @remarks A field, not a method, because the check runs on every hop of every chain.
138
- */
139
- owner: ResolverCallbacks;
140
-
141
120
  get graph(): ConstraintContext {
142
121
  if (this.#graph === undefined) {
143
122
  this.#graph = new DefaultConstraintContext(this.#resolutionPath, this.#resolutionStack, this.#currentOptions);
@@ -151,14 +130,21 @@ export class DefaultResolutionContext implements ResolutionContext {
151
130
  resolutionStack: Array<ResolutionFrame>,
152
131
  currentOptions: ResolveOptions | undefined,
153
132
  ): void {
154
- this.#resolver = resolver;
155
- this.owner = resolver;
156
- this.#resolutionPath = resolutionPath;
157
- this.#resolutionStack = resolutionStack;
133
+ // Compared before storing: a pooled context lives long enough to be in old space, so storing a
134
+ // pointer costs a write barrier, and a sync resolve hands every depth the same two arrays.
135
+ if (this.#resolver !== resolver) {
136
+ this.#resolver = resolver;
137
+ }
138
+ if (this.#resolutionPath !== resolutionPath) {
139
+ this.#resolutionPath = resolutionPath;
140
+ }
141
+ if (this.#resolutionStack !== resolutionStack) {
142
+ this.#resolutionStack = resolutionStack;
143
+ }
158
144
  this.#currentOptions = currentOptions;
159
- this.#graph = undefined;
160
- this.chainSettle = undefined;
161
- this.chainLevels = 0;
145
+ if (this.#graph !== undefined) {
146
+ this.#graph = undefined;
147
+ }
162
148
  }
163
149
 
164
150
  resolve<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value {
@@ -170,8 +156,8 @@ export class DefaultResolutionContext implements ResolutionContext {
170
156
 
171
157
  resolveAsync<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Promise<Value> {
172
158
  if (options === undefined) {
173
- // Hand the callee this context: an inner level of the same chain reuses it as-is.
174
- return this.#resolver.resolveAsyncFromContext(token, this.#resolutionPath, this.#resolutionStack, this);
159
+ // UNOWNED_BRANCH: this frame's array is a sync stack it will pop, so the lane must copy it.
160
+ return this.#resolver.resolveAsyncFromContext(token, this.#resolutionPath, this.#resolutionStack, UNOWNED_BRANCH);
175
161
  }
176
162
  return this.#resolver.resolveAsync(token, options, this.#resolutionPath, this.#resolutionStack);
177
163
  }
@@ -199,6 +185,166 @@ export class DefaultResolutionContext implements ResolutionContext {
199
185
  }
200
186
  }
201
187
 
188
+ /**
189
+ * One async level's resolution context, which is also its branch of the resolution path.
190
+ *
191
+ * @remarks Separate from {@link DefaultResolutionContext} so the sync lane's pooled context keeps
192
+ * reading its arrays as plain fields: only an async branch has a prefix to take. See
193
+ * `ARCHITECTURE.md` — an async level owns its branch of the path.
194
+ *
195
+ * @since 0.5.0-canary.9
196
+ */
197
+ export class AsyncLevelContext implements ResolutionContext {
198
+ readonly #resolver: ResolverCallbacks;
199
+ readonly #resolutionPath: OwnedBranchPath;
200
+ readonly #resolutionStack: OwnedBranchStack;
201
+ readonly #currentOptions: ResolveOptions | undefined;
202
+ readonly #branchDepth: BranchDepth;
203
+
204
+ /**
205
+ * @param resolutionPath - this level's own branch; the depth is read off it rather than passed,
206
+ * so the two cannot disagree about where this level sits
207
+ */
208
+ constructor(
209
+ resolver: ResolverCallbacks,
210
+ resolutionPath: OwnedBranchPath,
211
+ resolutionStack: OwnedBranchStack,
212
+ currentOptions: ResolveOptions | undefined,
213
+ ) {
214
+ this.#resolver = resolver;
215
+ this.#resolutionPath = resolutionPath;
216
+ this.#resolutionStack = resolutionStack;
217
+ this.#currentOptions = currentOptions;
218
+ this.#branchDepth = resolutionPath.length as BranchDepth;
219
+ }
220
+
221
+ #graph: ConstraintContext | undefined;
222
+ #exactPathCache: Array<string> | undefined;
223
+ #exactStackCache: Array<ResolutionFrame> | undefined;
224
+
225
+ get graph(): ConstraintContext {
226
+ if (this.#graph === undefined) {
227
+ this.#graph = new DefaultConstraintContext(this.#exactPath(), this.#exactStack(), this.#currentOptions);
228
+ }
229
+ return this.#graph;
230
+ }
231
+
232
+ // The path is append-only and a descendant may already have grown it past this level, so every
233
+ // caller but the async lane is handed this branch's prefix. It is fixed for the level's lifetime.
234
+ #exactPath(): Array<string> {
235
+ return (this.#exactPathCache ??= this.#resolutionPath.slice(0, this.#branchDepth));
236
+ }
237
+
238
+ #exactStack(): Array<ResolutionFrame> {
239
+ return (this.#exactStackCache ??= this.#resolutionStack.slice(0, this.#branchDepth));
240
+ }
241
+
242
+ resolve<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value {
243
+ if (options === undefined) {
244
+ return this.#resolver.resolveFromContext(token, this.#exactPath(), this.#exactStack());
245
+ }
246
+ return this.#resolver.resolve(token, options, this.#exactPath(), this.#exactStack());
247
+ }
248
+
249
+ resolveAsync<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Promise<Value> {
250
+ if (options === undefined) {
251
+ // The hot lane: the resolver reads this branch by depth, so nothing is materialized.
252
+ return this.#resolver.resolveAsyncFromContext(
253
+ token,
254
+ this.#resolutionPath,
255
+ this.#resolutionStack,
256
+ this.#branchDepth,
257
+ );
258
+ }
259
+ return this.#resolver.resolveAsync(token, options, this.#exactPath(), this.#exactStack());
260
+ }
261
+
262
+ resolveOptional<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value | undefined {
263
+ return this.#resolver.resolveOptional(token, options, this.#exactPath(), this.#exactStack());
264
+ }
265
+
266
+ resolveOptionalAsync<const Value>(
267
+ token: Token<Value> | Constructor<Value>,
268
+ options?: ResolveOptions,
269
+ ): Promise<Value | undefined> {
270
+ return this.#resolver.resolveOptionalAsync(token, options, this.#exactPath(), this.#exactStack());
271
+ }
272
+
273
+ resolveAll<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Array<Value> {
274
+ return this.#resolver.resolveAll(token, options, this.#exactPath(), this.#exactStack());
275
+ }
276
+
277
+ resolveAllAsync<const Value>(
278
+ token: Token<Value> | Constructor<Value>,
279
+ options?: ResolveOptions,
280
+ ): Promise<Array<Value>> {
281
+ return this.#resolver.resolveAllAsync(token, options, this.#exactPath(), this.#exactStack());
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The one context every level of an open synchronous factory cascade shares.
287
+ *
288
+ * @remarks It carries no per-level state at all: while the cascade is open, the resolver's arrays
289
+ * *are* this level's ancestor chain, so nothing has to be allocated per level. See
290
+ * `ARCHITECTURE.md` — the cascade lane.
291
+ *
292
+ * @since 0.5.0-canary.9
293
+ */
294
+ export class AsyncCascadeContext implements ResolutionContext {
295
+ readonly #resolver: ResolverCallbacks;
296
+ readonly #cascadePath: Array<string>;
297
+ readonly #cascadeStack: Array<ResolutionFrame>;
298
+
299
+ constructor(resolver: ResolverCallbacks, cascadePath: Array<string>, cascadeStack: Array<ResolutionFrame>) {
300
+ this.#resolver = resolver;
301
+ this.#cascadePath = cascadePath;
302
+ this.#cascadeStack = cascadeStack;
303
+ }
304
+
305
+ get graph(): ConstraintContext {
306
+ // Not memoized: this context outlives every level, so a cached graph would describe whichever
307
+ // level asked first. The cascade arrays are only this level's ancestors while it is open.
308
+ return new DefaultConstraintContext(this.#cascadePath, this.#cascadeStack, undefined);
309
+ }
310
+
311
+ resolve<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value {
312
+ if (options === undefined) {
313
+ return this.#resolver.resolveFromContext(token, this.#cascadePath, this.#cascadeStack);
314
+ }
315
+ return this.#resolver.resolve(token, options, this.#cascadePath, this.#cascadeStack);
316
+ }
317
+
318
+ resolveAsync<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Promise<Value> {
319
+ if (options === undefined) {
320
+ return this.#resolver.resolveAsyncFromCascade(token) as Promise<Value>;
321
+ }
322
+ return this.#resolver.resolveAsync(token, options, [...this.#cascadePath], [...this.#cascadeStack]);
323
+ }
324
+
325
+ resolveOptional<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Value | undefined {
326
+ return this.#resolver.resolveOptional(token, options, this.#cascadePath, this.#cascadeStack);
327
+ }
328
+
329
+ resolveOptionalAsync<const Value>(
330
+ token: Token<Value> | Constructor<Value>,
331
+ options?: ResolveOptions,
332
+ ): Promise<Value | undefined> {
333
+ return this.#resolver.resolveOptionalAsync(token, options, [...this.#cascadePath], [...this.#cascadeStack]);
334
+ }
335
+
336
+ resolveAll<const Value>(token: Token<Value> | Constructor<Value>, options?: ResolveOptions): Array<Value> {
337
+ return this.#resolver.resolveAll(token, options, this.#cascadePath, this.#cascadeStack);
338
+ }
339
+
340
+ resolveAllAsync<const Value>(
341
+ token: Token<Value> | Constructor<Value>,
342
+ options?: ResolveOptions,
343
+ ): Promise<Array<Value>> {
344
+ return this.#resolver.resolveAllAsync(token, options, [...this.#cascadePath], [...this.#cascadeStack]);
345
+ }
346
+ }
347
+
202
348
  class DefaultConstraintContext implements ConstraintContext {
203
349
  readonly resolutionPath: ReadonlyArray<string>;
204
350
  readonly resolutionStack: ReadonlyArray<ResolutionFrame>;
@@ -6,13 +6,13 @@
6
6
  import type { Binding } from "#/binding";
7
7
  import { NO_INSTANCE } from "#/binding";
8
8
  import type { ConstructorInvocation } from "#/constructor-type";
9
- import type { InjectionDescriptor } from "#/decorators/inject";
10
9
  import { AsyncResolutionError } from "#/errors";
11
10
  import type { ConstructorMetadata } from "#/metadata/metadata-types";
11
+ import type { DependencySlot } from "#/resolution/resolve-options";
12
12
  import { injectionSlotToResolveOptions } from "#/resolution/resolve-options";
13
13
  import type { Token } from "#/token";
14
14
  import { tokenName } from "#/token";
15
- import type { BindingScope, Constructor, ResolutionFrame, ResolveOptions } from "#/types";
15
+ import type { Constructor, ResolutionFrame, ResolveOptions } from "#/types";
16
16
 
17
17
  // Past this depth a dependency escapes to the runtime path rather than inlining further —
18
18
  // compiled closures nest one JS frame per level, and pathological graphs are the runtime's job.
@@ -160,12 +160,12 @@ export class InstantiationPlanCompiler {
160
160
  * @remarks Anything but a plain required single dependency escapes to the runtime path.
161
161
  */
162
162
  #compileInjectionThunk(
163
- descriptor: InjectionDescriptor,
163
+ descriptor: DependencySlot,
164
164
  compileStack: Set<Binding["id"]>,
165
165
  depth: number,
166
166
  ancestors: ReadonlyArray<Binding>,
167
167
  ): DependencyCompileResult {
168
- const token = descriptor.token as Token<unknown> | Constructor;
168
+ const token = descriptor.token;
169
169
  const options = injectionSlotToResolveOptions(descriptor);
170
170
  if (descriptor.multi) {
171
171
  return this.#compileEscapeThunk(token, ancestors, "all", options);
@@ -258,7 +258,7 @@ export class InstantiationPlanCompiler {
258
258
  return () => value;
259
259
  }
260
260
  }
261
- const scope = (binding as Binding & { scope: BindingScope }).scope ?? "transient";
261
+ const scope = binding.scope;
262
262
  if (scope === "singleton") {
263
263
  // Cached-singleton read; the first materialization escapes so it sees the same ancestors
264
264
  // (and therefore the same cycle detection) the interpreted path would have built.
@@ -15,18 +15,22 @@ export class LifecycleManager {
15
15
  #deactivationHooks: Map<Token<unknown> | Constructor, Array<DeactivationHandler<unknown>>> | undefined;
16
16
  #activationVersion = 0;
17
17
 
18
+ // One-entry cache in front of the map: a resolve loop asks about the same token over and over,
19
+ // and registration is the only thing that can change the answer.
20
+ #cachedToken: Token<unknown> | Constructor | undefined;
21
+ #cachedHooks: Array<ActivationHandler<unknown>> | undefined;
22
+
18
23
  registerActivation<const Value>(token: Token<Value> | Constructor<Value>, handler: ActivationHandler<Value>): void {
19
24
  this.#activationVersion += 1;
25
+ this.#cachedToken = undefined;
26
+ this.#cachedHooks = undefined;
20
27
  // ✓ TS6.0: Map.getOrInsert (ES2025)
21
28
  const list = (this.#activationHooks ??= new Map()).getOrInsert(token as Token<unknown> | Constructor, []);
22
29
  list.push(handler as ActivationHandler<unknown>);
23
30
  }
24
31
 
25
32
  hasActivationHandlers<const Value>(token: Token<Value> | Constructor<Value>): boolean {
26
- if (this.#activationHooks === undefined) {
27
- return false;
28
- }
29
- const list = this.#activationHooks.get(token as Token<unknown> | Constructor);
33
+ const list = this.activationHandlersFor(token);
30
34
  return list !== undefined && list.length > 0;
31
35
  }
32
36
 
@@ -38,7 +42,18 @@ export class LifecycleManager {
38
42
  activationHandlersFor<const Value>(
39
43
  token: Token<Value> | Constructor<Value>,
40
44
  ): ReadonlyArray<ActivationHandler<unknown>> | undefined {
41
- return this.#activationHooks?.get(token as Token<unknown> | Constructor);
45
+ const hooks = this.#activationHooks;
46
+ if (hooks === undefined) {
47
+ return undefined;
48
+ }
49
+ const key = token as Token<unknown> | Constructor;
50
+ if (key === this.#cachedToken) {
51
+ return this.#cachedHooks;
52
+ }
53
+ const list = hooks.get(key);
54
+ this.#cachedToken = key;
55
+ this.#cachedHooks = list;
56
+ return list;
42
57
  }
43
58
 
44
59
  registerDeactivation<const Value>(
@@ -58,18 +73,10 @@ export class LifecycleManager {
58
73
  let activatedInstance: Value = instance;
59
74
 
60
75
  // 1. @postConstruct() — after TC39 construction (constructor + accessor addInitializer callbacks)
61
- if (binding.kind === "class") {
62
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
63
- if (lifecycle?.postConstruct && lifecycle.postConstruct.length > 0) {
64
- for (const methodName of lifecycle.postConstruct) {
65
- const method = (activatedInstance as Record<string, unknown>)[methodName];
66
- if (typeof method === "function") {
67
- const hookResult = (method as () => unknown).call(activatedInstance);
68
- if (hookResult instanceof Promise) {
69
- await hookResult;
70
- }
71
- }
72
- }
76
+ for (const methodName of lifecycleMethods(binding, metadataReader, "postConstruct")) {
77
+ const hookResult = callHook(activatedInstance, methodName);
78
+ if (hookResult instanceof Promise) {
79
+ await hookResult;
73
80
  }
74
81
  }
75
82
 
@@ -100,18 +107,9 @@ export class LifecycleManager {
100
107
  let activatedInstance: Value = instance;
101
108
 
102
109
  // 1. @postConstruct() — must be sync (instance fully constructed per TC39 order)
103
- if (binding.kind === "class") {
104
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
105
- if (lifecycle?.postConstruct && lifecycle.postConstruct.length > 0) {
106
- for (const methodName of lifecycle.postConstruct) {
107
- const method = (activatedInstance as Record<string, unknown>)[methodName];
108
- if (typeof method === "function") {
109
- const hookResult = (method as () => unknown).call(activatedInstance);
110
- if (hookResult instanceof Promise) {
111
- throw new AsyncActivationError(tokenName(binding.token), "postConstruct", methodName);
112
- }
113
- }
114
- }
110
+ for (const methodName of lifecycleMethods(binding, metadataReader, "postConstruct")) {
111
+ if (callHook(activatedInstance, methodName) instanceof Promise) {
112
+ throw new AsyncActivationError(tokenName(binding.token), "postConstruct", methodName);
115
113
  }
116
114
  }
117
115
 
@@ -167,18 +165,10 @@ export class LifecycleManager {
167
165
  }
168
166
 
169
167
  // 3. @preDestroy() — all methods in declaration order
170
- if (binding.kind === "class") {
171
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
172
- if (lifecycle?.preDestroy && lifecycle.preDestroy.length > 0) {
173
- for (const methodName of lifecycle.preDestroy) {
174
- const method = (instance as Record<string, unknown>)[methodName];
175
- if (typeof method === "function") {
176
- const hookResult = (method as () => unknown).call(instance);
177
- if (hookResult instanceof Promise) {
178
- await hookResult;
179
- }
180
- }
181
- }
168
+ for (const methodName of lifecycleMethods(binding, metadataReader, "preDestroy")) {
169
+ const hookResult = callHook(instance, methodName);
170
+ if (hookResult instanceof Promise) {
171
+ await hookResult;
182
172
  }
183
173
  }
184
174
  }
@@ -207,23 +197,35 @@ export class LifecycleManager {
207
197
  }
208
198
 
209
199
  // 3. @preDestroy()
210
- if (binding.kind === "class") {
211
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
212
- if (lifecycle?.preDestroy && lifecycle.preDestroy.length > 0) {
213
- for (const methodName of lifecycle.preDestroy) {
214
- const method = (instance as Record<string, unknown>)[methodName];
215
- if (typeof method === "function") {
216
- const hookResult = (method as () => unknown).call(instance);
217
- if (hookResult instanceof Promise) {
218
- throw new AsyncDeactivationError(tokenDisplayName);
219
- }
220
- }
221
- }
200
+ for (const methodName of lifecycleMethods(binding, metadataReader, "preDestroy")) {
201
+ if (callHook(instance, methodName) instanceof Promise) {
202
+ throw new AsyncDeactivationError(tokenDisplayName);
222
203
  }
223
204
  }
224
205
  }
206
+
225
207
  /** Whether the deferred table behind `#activationHooks` has had to be built. */
226
208
  get isBuilt(): boolean {
227
209
  return this.#activationHooks !== undefined;
228
210
  }
229
211
  }
212
+
213
+ const NO_METHODS: ReadonlyArray<string> = [];
214
+
215
+ /** The `@postConstruct` / `@preDestroy` methods a binding declares — only a class can declare any. */
216
+ function lifecycleMethods<const Value>(
217
+ binding: Binding<Value>,
218
+ metadataReader: MetadataReader,
219
+ phase: "postConstruct" | "preDestroy",
220
+ ): ReadonlyArray<string> {
221
+ if (binding.kind !== "class") {
222
+ return NO_METHODS;
223
+ }
224
+ return metadataReader.getLifecycleMetadata(binding.target)?.[phase] ?? NO_METHODS;
225
+ }
226
+
227
+ /** Invokes a hook by name, tolerating a name whose member is not (or no longer) a method. */
228
+ function callHook(instance: unknown, methodName: string): unknown {
229
+ const method = (instance as Record<string, unknown>)[methodName];
230
+ return typeof method === "function" ? (method as () => unknown).call(instance) : undefined;
231
+ }