@codefast/di 0.5.0-canary.7 → 0.5.0-canary.9

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 (114) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/README.md +6 -2
  3. package/dist/binding.d.ts +85 -24
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +55 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/constructor-type.d.ts +4 -5
  8. package/dist/constructor-type.d.ts.map +1 -1
  9. package/dist/container/binding-builders.d.ts +32 -11
  10. package/dist/container/binding-builders.d.ts.map +1 -1
  11. package/dist/container/binding-builders.js +144 -192
  12. package/dist/container/binding-builders.js.map +1 -1
  13. package/dist/container/container.d.ts.map +1 -1
  14. package/dist/container/container.js +141 -201
  15. package/dist/container/container.js.map +1 -1
  16. package/dist/decorators/inject.d.ts +2 -4
  17. package/dist/decorators/inject.d.ts.map +1 -1
  18. package/dist/decorators/inject.js.map +1 -1
  19. package/dist/errors.d.ts +24 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +30 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/introspection/inspector.d.ts +0 -1
  28. package/dist/introspection/inspector.d.ts.map +1 -1
  29. package/dist/introspection/inspector.js +3 -8
  30. package/dist/introspection/inspector.js.map +1 -1
  31. package/dist/metadata/metadata-keys.d.ts +3 -6
  32. package/dist/metadata/metadata-keys.d.ts.map +1 -1
  33. package/dist/metadata/metadata-keys.js +3 -6
  34. package/dist/metadata/metadata-keys.js.map +1 -1
  35. package/dist/registry.d.ts +14 -2
  36. package/dist/registry.d.ts.map +1 -1
  37. package/dist/registry.js +81 -78
  38. package/dist/registry.js.map +1 -1
  39. package/dist/resolution/activation-need.d.ts +27 -0
  40. package/dist/resolution/activation-need.d.ts.map +1 -0
  41. package/dist/resolution/activation-need.js +68 -0
  42. package/dist/resolution/activation-need.js.map +1 -0
  43. package/dist/resolution/binding-lookup-cache.d.ts +41 -0
  44. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -0
  45. package/dist/resolution/binding-lookup-cache.js +118 -0
  46. package/dist/resolution/binding-lookup-cache.js.map +1 -0
  47. package/dist/resolution/binding-scope.d.ts +5 -2
  48. package/dist/resolution/binding-scope.d.ts.map +1 -1
  49. package/dist/resolution/binding-scope.js +6 -17
  50. package/dist/resolution/binding-scope.js.map +1 -1
  51. package/dist/resolution/binding-select.d.ts +8 -1
  52. package/dist/resolution/binding-select.d.ts.map +1 -1
  53. package/dist/resolution/binding-select.js +14 -36
  54. package/dist/resolution/binding-select.js.map +1 -1
  55. package/dist/resolution/class-introspector.d.ts +27 -0
  56. package/dist/resolution/class-introspector.d.ts.map +1 -0
  57. package/dist/resolution/class-introspector.js +60 -0
  58. package/dist/resolution/class-introspector.js.map +1 -0
  59. package/dist/resolution/diagnostics.d.ts +41 -0
  60. package/dist/resolution/diagnostics.d.ts.map +1 -0
  61. package/dist/resolution/diagnostics.js +18 -0
  62. package/dist/resolution/diagnostics.js.map +1 -0
  63. package/dist/resolution/environment.d.ts +48 -1
  64. package/dist/resolution/environment.d.ts.map +1 -1
  65. package/dist/resolution/environment.js +134 -5
  66. package/dist/resolution/environment.js.map +1 -1
  67. package/dist/resolution/instantiation-plan.d.ts +15 -15
  68. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  69. package/dist/resolution/instantiation-plan.js +69 -49
  70. package/dist/resolution/instantiation-plan.js.map +1 -1
  71. package/dist/resolution/lifecycle.d.ts +2 -0
  72. package/dist/resolution/lifecycle.d.ts.map +1 -1
  73. package/dist/resolution/lifecycle.js +60 -62
  74. package/dist/resolution/lifecycle.js.map +1 -1
  75. package/dist/resolution/resolution-path.d.ts +84 -17
  76. package/dist/resolution/resolution-path.d.ts.map +1 -1
  77. package/dist/resolution/resolution-path.js +68 -23
  78. package/dist/resolution/resolution-path.js.map +1 -1
  79. package/dist/resolution/resolve-options.d.ts +41 -4
  80. package/dist/resolution/resolve-options.d.ts.map +1 -1
  81. package/dist/resolution/resolve-options.js +25 -1
  82. package/dist/resolution/resolve-options.js.map +1 -1
  83. package/dist/resolution/resolver.d.ts +33 -10
  84. package/dist/resolution/resolver.d.ts.map +1 -1
  85. package/dist/resolution/resolver.js +471 -830
  86. package/dist/resolution/resolver.js.map +1 -1
  87. package/dist/resolution/scope.d.ts +11 -15
  88. package/dist/resolution/scope.d.ts.map +1 -1
  89. package/dist/resolution/scope.js +55 -43
  90. package/dist/resolution/scope.js.map +1 -1
  91. package/package.json +10 -106
  92. package/src/binding.ts +146 -24
  93. package/src/constructor-type.ts +4 -5
  94. package/src/container/binding-builders.ts +184 -284
  95. package/src/container/container.ts +161 -221
  96. package/src/decorators/inject.ts +3 -5
  97. package/src/errors.ts +38 -0
  98. package/src/index.ts +4 -1
  99. package/src/introspection/inspector.ts +3 -9
  100. package/src/metadata/metadata-keys.ts +3 -6
  101. package/src/registry.ts +90 -94
  102. package/src/resolution/activation-need.ts +85 -0
  103. package/src/resolution/binding-lookup-cache.ts +148 -0
  104. package/src/resolution/binding-scope.ts +6 -17
  105. package/src/resolution/binding-select.ts +15 -39
  106. package/src/resolution/class-introspector.ts +74 -0
  107. package/src/resolution/diagnostics.ts +43 -0
  108. package/src/resolution/environment.ts +181 -5
  109. package/src/resolution/instantiation-plan.ts +116 -64
  110. package/src/resolution/lifecycle.ts +69 -62
  111. package/src/resolution/resolution-path.ts +122 -43
  112. package/src/resolution/resolve-options.ts +51 -4
  113. package/src/resolution/resolver.ts +649 -1081
  114. package/src/resolution/scope.ts +58 -47
@@ -1,28 +1,21 @@
1
1
  /**
2
- * Compiler for the resolver's Dagger-style instantiation plans: a transient
3
- * class or resolved-factory binding whose dependency subgraph is pure static
4
- * (class/constant/cached-singleton deps, no activation hooks or postConstruct)
5
- * compiles once into a nested-constructor/factory closure, cycle-checked at
6
- * compile time.
2
+ * Compiles a transient class or resolved-factory binding into a nested-constructor closure.
7
3
  *
8
- * Compilation is cold-path — it runs once per (binding, cache version). The
9
- * closures it returns ARE the hot path and touch nothing but their captures.
10
- * Anything dynamic refuses to compile so the runtime cycle guard stays in
11
- * charge and error semantics never change.
4
+ * @see `ARCHITECTURE.md` — the escape contract every dependency the compiler cannot inline must honour.
12
5
  */
13
6
  import type { Binding } from "#/binding";
7
+ import { NO_INSTANCE } from "#/binding";
14
8
  import type { ConstructorInvocation } from "#/constructor-type";
15
- import type { InjectionDescriptor } from "#/decorators/inject";
16
9
  import { AsyncResolutionError } from "#/errors";
17
10
  import type { ConstructorMetadata } from "#/metadata/metadata-types";
11
+ import type { DependencySlot } from "#/resolution/resolve-options";
18
12
  import { injectionSlotToResolveOptions } from "#/resolution/resolve-options";
19
- import type { ScopeManager } from "#/resolution/scope";
20
- import { SINGLETON_MISS } from "#/resolution/scope";
21
13
  import type { Token } from "#/token";
22
14
  import { tokenName } from "#/token";
23
- import type { BindingScope, Constructor } from "#/types";
15
+ import type { Constructor, ResolutionFrame, ResolveOptions } from "#/types";
24
16
 
25
- // Bail out of pathological graphs — the runtime path handles them correctly.
17
+ // Past this depth a dependency escapes to the runtime path rather than inlining further —
18
+ // compiled closures nest one JS frame per level, and pathological graphs are the runtime's job.
26
19
  const PLAN_DEPTH_LIMIT = 32;
27
20
 
28
21
  /**
@@ -41,13 +34,20 @@ export const PLAN_RETRY: unique symbol = Symbol("di:plan-retry");
41
34
  export type InstantiationPlanCompileResult = (() => unknown) | null | typeof PLAN_RETRY;
42
35
 
43
36
  /**
44
- * A dependency's terminal binding plus the scope cache of the resolver that owns it.
37
+ * What compiling one *dependency* can yield.
38
+ *
39
+ * @remarks No `null`: a dependency escapes rather than failing, so "no plan" is only ever a
40
+ * verdict on a plan's root.
41
+ */
42
+ type DependencyCompileResult = (() => unknown) | typeof PLAN_RETRY;
43
+
44
+ /**
45
+ * A dependency's terminal binding — all a compiled thunk needs.
45
46
  *
46
47
  * @since 0.5.0-canary.7
47
48
  */
48
49
  export interface InstantiationPlanDependencyEntry {
49
50
  readonly binding: Binding;
50
- readonly ownerScope: ScopeManager;
51
51
  }
52
52
 
53
53
  /**
@@ -64,10 +64,26 @@ export interface InstantiationPlanHost {
64
64
  getConstructorMetadata(target: Constructor): ConstructorMetadata | undefined;
65
65
  /** Options-less lookup with alias hops folded; `null` when the fast lane can't answer. */
66
66
  lookupDependencyEntry(token: Token<unknown> | Constructor): InstantiationPlanDependencyEntry | null;
67
- /** Full runtime resolve — used by singleton thunks for the first materialization. */
68
- resolveFallback(token: Token<unknown> | Constructor): unknown;
67
+ /** The frame the interpreted path pushes for this binding, so escapes can replay it. */
68
+ getResolutionFrame(binding: Binding): ResolutionFrame;
69
+ /** Runtime resolve for an escaped dependency, seeded with the ancestors above it. */
70
+ resolveEscaped(
71
+ token: Token<unknown> | Constructor,
72
+ options: ResolveOptions | undefined,
73
+ arity: EscapeArity,
74
+ resolutionPath: Array<string>,
75
+ resolutionStack: Array<ResolutionFrame>,
76
+ ): unknown;
69
77
  }
70
78
 
79
+ /**
80
+ * Which resolve an escaped dependency replays — mirrors how the interpreted path dispatches
81
+ * a constructor param.
82
+ *
83
+ * @since 0.5.0-canary.8
84
+ */
85
+ export type EscapeArity = "all" | "optional" | "single";
86
+
71
87
  /**
72
88
  * @since 0.3.16-canary.1
73
89
  */
@@ -80,8 +96,26 @@ export class InstantiationPlanCompiler {
80
96
 
81
97
  compile(binding: Binding & { kind: "class" | "resolved" }): InstantiationPlanCompileResult {
82
98
  return binding.kind === "class"
83
- ? this.#compileClassPlan(binding, new Set(), 0)
84
- : this.#compileResolvedPlan(binding, new Set(), 0);
99
+ ? this.#compileClassPlan(binding, new Set(), 0, [])
100
+ : this.#compileResolvedPlan(binding, new Set(), 0, []);
101
+ }
102
+
103
+ /**
104
+ * Re-entry into the runtime resolver for a dependency the plan can't see through.
105
+ *
106
+ * The ancestors are fixed at compile time, so the seeds are built once; each call copies
107
+ * them because the resolver pushes and pops on the arrays it is given.
108
+ */
109
+ #compileEscapeThunk(
110
+ token: Token<unknown> | Constructor,
111
+ ancestors: ReadonlyArray<Binding>,
112
+ arity: EscapeArity = "single",
113
+ options?: ResolveOptions,
114
+ ): () => unknown {
115
+ const host = this.#host;
116
+ const frames = ancestors.map((ancestor) => host.getResolutionFrame(ancestor));
117
+ const names = frames.map((frame) => frame.tokenName);
118
+ return () => host.resolveEscaped(token, options, arity, [...names], [...frames]);
85
119
  }
86
120
 
87
121
  // A resolved binding declares its deps as explicit descriptors — same rules as
@@ -90,21 +124,20 @@ export class InstantiationPlanCompiler {
90
124
  binding: Binding & { kind: "resolved" },
91
125
  compileStack: Set<Binding["id"]>,
92
126
  depth: number,
127
+ ancestors: ReadonlyArray<Binding>,
93
128
  ): InstantiationPlanCompileResult {
94
- if (depth > PLAN_DEPTH_LIMIT || compileStack.has(binding.id)) {
95
- return null;
96
- }
97
129
  if (binding.onActivation !== undefined || this.#host.hasActivationHandlers(binding.token)) {
98
130
  return null;
99
131
  }
100
132
  const factory = binding.factory;
101
133
  const tokenDisplayName = tokenName(binding.token);
102
134
  const depThunks = new Array<() => unknown>(binding.deps.length);
135
+ const depAncestors = [...ancestors, binding];
103
136
  compileStack.add(binding.id);
104
137
  try {
105
138
  for (let index = 0; index < binding.deps.length; index += 1) {
106
- const thunk = this.#compileDescriptorThunk(binding.deps[index]!, compileStack, depth);
107
- if (thunk === null || thunk === PLAN_RETRY) {
139
+ const thunk = this.#compileInjectionThunk(binding.deps[index]!, compileStack, depth, depAncestors);
140
+ if (thunk === PLAN_RETRY) {
108
141
  return thunk;
109
142
  }
110
143
  depThunks[index] = thunk;
@@ -121,29 +154,41 @@ export class InstantiationPlanCompiler {
121
154
  };
122
155
  }
123
156
 
124
- #compileDescriptorThunk(
125
- descriptor: InjectionDescriptor,
157
+ /**
158
+ * One dependency of a plan node — a constructor param or a `toResolved` descriptor.
159
+ *
160
+ * @remarks Anything but a plain required single dependency escapes to the runtime path.
161
+ */
162
+ #compileInjectionThunk(
163
+ descriptor: DependencySlot,
126
164
  compileStack: Set<Binding["id"]>,
127
165
  depth: number,
128
- ): InstantiationPlanCompileResult {
129
- if (descriptor.multi || descriptor.optional || injectionSlotToResolveOptions(descriptor) !== undefined) {
130
- return null;
166
+ ancestors: ReadonlyArray<Binding>,
167
+ ): DependencyCompileResult {
168
+ const token = descriptor.token;
169
+ const options = injectionSlotToResolveOptions(descriptor);
170
+ if (descriptor.multi) {
171
+ return this.#compileEscapeThunk(token, ancestors, "all", options);
172
+ }
173
+ if (descriptor.optional) {
174
+ return this.#compileEscapeThunk(token, ancestors, "optional", options);
131
175
  }
132
- const entry = this.#host.lookupDependencyEntry(descriptor.token as Token<unknown> | Constructor);
176
+ if (options !== undefined) {
177
+ return this.#compileEscapeThunk(token, ancestors, "single", options);
178
+ }
179
+ const entry = this.#host.lookupDependencyEntry(token);
133
180
  if (entry === null) {
134
- return null;
181
+ return this.#compileEscapeThunk(token, ancestors);
135
182
  }
136
- return this.#compileDepThunk(entry, compileStack, depth);
183
+ return this.#compileDepThunk(entry, compileStack, depth, ancestors);
137
184
  }
138
185
 
139
186
  #compileClassPlan(
140
187
  binding: Binding & { kind: "class" },
141
188
  compileStack: Set<Binding["id"]>,
142
189
  depth: number,
190
+ ancestors: ReadonlyArray<Binding>,
143
191
  ): InstantiationPlanCompileResult {
144
- if (depth > PLAN_DEPTH_LIMIT || compileStack.has(binding.id)) {
145
- return null;
146
- }
147
192
  if (binding.onActivation !== undefined || this.#host.hasActivationHandlers(binding.token)) {
148
193
  return null;
149
194
  }
@@ -166,19 +211,12 @@ export class InstantiationPlanCompiler {
166
211
  return () => new invokable();
167
212
  }
168
213
  const depThunks = new Array<() => unknown>(params.length);
214
+ const depAncestors = [...ancestors, binding];
169
215
  compileStack.add(binding.id);
170
216
  try {
171
217
  for (let index = 0; index < params.length; index += 1) {
172
- const param = params[index]!;
173
- if (param.multi || param.optional || injectionSlotToResolveOptions(param) !== undefined) {
174
- return null;
175
- }
176
- const entry = this.#host.lookupDependencyEntry(param.token);
177
- if (entry === null) {
178
- return null;
179
- }
180
- const thunk = this.#compileDepThunk(entry, compileStack, depth);
181
- if (thunk === null || thunk === PLAN_RETRY) {
218
+ const thunk = this.#compileInjectionThunk(params[index]!, compileStack, depth, depAncestors);
219
+ if (thunk === PLAN_RETRY) {
182
220
  return thunk;
183
221
  }
184
222
  depThunks[index] = thunk;
@@ -211,30 +249,44 @@ export class InstantiationPlanCompiler {
211
249
  entry: InstantiationPlanDependencyEntry,
212
250
  compileStack: Set<Binding["id"]>,
213
251
  depth: number,
214
- ): InstantiationPlanCompileResult {
215
- const { binding, ownerScope } = entry;
216
- if (binding.kind === "constant") {
217
- if (binding.onActivation !== undefined || this.#host.hasActivationHandlers(binding.token)) {
218
- return null;
252
+ ancestors: ReadonlyArray<Binding>,
253
+ ): DependencyCompileResult {
254
+ const { binding } = entry;
255
+ if (binding.kind === "constant" && binding.onActivation === undefined) {
256
+ if (!this.#host.hasActivationHandlers(binding.token)) {
257
+ const value = binding.value;
258
+ return () => value;
219
259
  }
220
- const value = binding.value;
221
- return () => value;
222
260
  }
223
- const scope = (binding as Binding & { scope: BindingScope }).scope ?? "transient";
261
+ const scope = binding.scope;
224
262
  if (scope === "singleton") {
225
- // Cached-singleton read with a full-resolve fallback for the first materialization.
226
- const host = this.#host;
227
- const bindingId = binding.id;
228
- const singletonToken = binding.token;
263
+ // Cached-singleton read; the first materialization escapes so it sees the same ancestors
264
+ // (and therefore the same cycle detection) the interpreted path would have built.
265
+ const escape = this.#compileEscapeThunk(binding.token, ancestors);
266
+ const singletonBinding = binding;
229
267
  return () => {
230
- const cachedSingleton = ownerScope.peekSingleton(bindingId);
231
- return cachedSingleton === SINGLETON_MISS ? host.resolveFallback(singletonToken) : cachedSingleton;
268
+ const cached = singletonBinding.instance;
269
+ return cached === NO_INSTANCE ? escape() : cached;
232
270
  };
233
271
  }
234
- if (scope === "transient" && binding.kind === "class") {
235
- return this.#compileClassPlan(binding as Binding & { kind: "class" }, compileStack, depth + 1);
272
+ if (
273
+ scope === "transient" &&
274
+ binding.kind === "class" &&
275
+ depth < PLAN_DEPTH_LIMIT &&
276
+ !compileStack.has(binding.id)
277
+ ) {
278
+ const inlined = this.#compileClassPlan(
279
+ binding as Binding & { kind: "class" },
280
+ compileStack,
281
+ depth + 1,
282
+ ancestors,
283
+ );
284
+ if (inlined !== null) {
285
+ return inlined;
286
+ }
236
287
  }
237
- // Dynamic/resolved/scoped deps keep the runtime path (and its cycle guard).
238
- return null;
288
+ // Anything opaque — a factory, a scoped binding, an activation hook, a class the compiler
289
+ // declined — runs on the runtime path, seeded with this plan's ancestors.
290
+ return this.#compileEscapeThunk(binding.token, ancestors);
239
291
  }
240
292
  }
@@ -9,23 +9,28 @@ import type { ActivationHandler, Constructor, DeactivationHandler, ResolutionCon
9
9
  * @since 0.3.16-canary.0
10
10
  */
11
11
  export class LifecycleManager {
12
- // Container-level activation/deactivation hooks per token
13
- readonly #activationHooks = new Map<Token<unknown> | Constructor, Array<ActivationHandler<unknown>>>();
14
- readonly #deactivationHooks = new Map<Token<unknown> | Constructor, Array<DeactivationHandler<unknown>>>();
12
+ // Container-level activation/deactivation hooks per token — most containers register none, so
13
+ // both tables stay unallocated until the first hook arrives.
14
+ #activationHooks: Map<Token<unknown> | Constructor, Array<ActivationHandler<unknown>>> | undefined;
15
+ #deactivationHooks: Map<Token<unknown> | Constructor, Array<DeactivationHandler<unknown>>> | undefined;
15
16
  #activationVersion = 0;
16
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
+
17
23
  registerActivation<const Value>(token: Token<Value> | Constructor<Value>, handler: ActivationHandler<Value>): void {
18
24
  this.#activationVersion += 1;
25
+ this.#cachedToken = undefined;
26
+ this.#cachedHooks = undefined;
19
27
  // ✓ TS6.0: Map.getOrInsert (ES2025)
20
- const list = this.#activationHooks.getOrInsert(token as Token<unknown> | Constructor, []);
28
+ const list = (this.#activationHooks ??= new Map()).getOrInsert(token as Token<unknown> | Constructor, []);
21
29
  list.push(handler as ActivationHandler<unknown>);
22
30
  }
23
31
 
24
32
  hasActivationHandlers<const Value>(token: Token<Value> | Constructor<Value>): boolean {
25
- if (this.#activationHooks.size === 0) {
26
- return false;
27
- }
28
- const list = this.#activationHooks.get(token as Token<unknown> | Constructor);
33
+ const list = this.activationHandlersFor(token);
29
34
  return list !== undefined && list.length > 0;
30
35
  }
31
36
 
@@ -37,14 +42,25 @@ export class LifecycleManager {
37
42
  activationHandlersFor<const Value>(
38
43
  token: Token<Value> | Constructor<Value>,
39
44
  ): ReadonlyArray<ActivationHandler<unknown>> | undefined {
40
- 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;
41
57
  }
42
58
 
43
59
  registerDeactivation<const Value>(
44
60
  token: Token<Value> | Constructor<Value>,
45
61
  handler: DeactivationHandler<Value>,
46
62
  ): void {
47
- const list = this.#deactivationHooks.getOrInsert(token as Token<unknown> | Constructor, []);
63
+ const list = (this.#deactivationHooks ??= new Map()).getOrInsert(token as Token<unknown> | Constructor, []);
48
64
  list.push(handler as DeactivationHandler<unknown>);
49
65
  }
50
66
 
@@ -57,18 +73,10 @@ export class LifecycleManager {
57
73
  let activatedInstance: Value = instance;
58
74
 
59
75
  // 1. @postConstruct() — after TC39 construction (constructor + accessor addInitializer callbacks)
60
- if (binding.kind === "class") {
61
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
62
- if (lifecycle?.postConstruct && lifecycle.postConstruct.length > 0) {
63
- for (const methodName of lifecycle.postConstruct) {
64
- const method = (activatedInstance as Record<string, unknown>)[methodName];
65
- if (typeof method === "function") {
66
- const hookResult = (method as () => unknown).call(activatedInstance);
67
- if (hookResult instanceof Promise) {
68
- await hookResult;
69
- }
70
- }
71
- }
76
+ for (const methodName of lifecycleMethods(binding, metadataReader, "postConstruct")) {
77
+ const hookResult = callHook(activatedInstance, methodName);
78
+ if (hookResult instanceof Promise) {
79
+ await hookResult;
72
80
  }
73
81
  }
74
82
 
@@ -79,7 +87,7 @@ export class LifecycleManager {
79
87
  }
80
88
 
81
89
  // 3. container-level onActivation
82
- const containerHooks = this.#activationHooks.get(binding.token as Token<unknown> | Constructor);
90
+ const containerHooks = this.#activationHooks?.get(binding.token as Token<unknown> | Constructor);
83
91
  if (containerHooks !== undefined) {
84
92
  for (const hook of containerHooks) {
85
93
  const activationResult = hook(resolutionContext, activatedInstance);
@@ -99,18 +107,9 @@ export class LifecycleManager {
99
107
  let activatedInstance: Value = instance;
100
108
 
101
109
  // 1. @postConstruct() — must be sync (instance fully constructed per TC39 order)
102
- if (binding.kind === "class") {
103
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
104
- if (lifecycle?.postConstruct && lifecycle.postConstruct.length > 0) {
105
- for (const methodName of lifecycle.postConstruct) {
106
- const method = (activatedInstance as Record<string, unknown>)[methodName];
107
- if (typeof method === "function") {
108
- const hookResult = (method as () => unknown).call(activatedInstance);
109
- if (hookResult instanceof Promise) {
110
- throw new AsyncActivationError(tokenName(binding.token), "postConstruct", methodName);
111
- }
112
- }
113
- }
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);
114
113
  }
115
114
  }
116
115
 
@@ -125,7 +124,7 @@ export class LifecycleManager {
125
124
 
126
125
  // 3. container-level onActivation (must be sync)
127
126
  const tokenDisplayName = tokenName(binding.token);
128
- const containerHooks = this.#activationHooks.get(binding.token as Token<unknown> | Constructor);
127
+ const containerHooks = this.#activationHooks?.get(binding.token as Token<unknown> | Constructor);
129
128
  if (containerHooks !== undefined) {
130
129
  for (const hook of containerHooks) {
131
130
  const activationResult = hook(resolutionContext, activatedInstance);
@@ -147,7 +146,7 @@ export class LifecycleManager {
147
146
  const tokenKey = binding.token as Token<unknown> | Constructor;
148
147
 
149
148
  // 1. container-level onDeactivation
150
- const containerHooks = this.#deactivationHooks.get(tokenKey);
149
+ const containerHooks = this.#deactivationHooks?.get(tokenKey);
151
150
  if (containerHooks !== undefined) {
152
151
  for (const hook of containerHooks) {
153
152
  const hookResult = hook(instance);
@@ -166,18 +165,10 @@ export class LifecycleManager {
166
165
  }
167
166
 
168
167
  // 3. @preDestroy() — all methods in declaration order
169
- if (binding.kind === "class") {
170
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
171
- if (lifecycle?.preDestroy && lifecycle.preDestroy.length > 0) {
172
- for (const methodName of lifecycle.preDestroy) {
173
- const method = (instance as Record<string, unknown>)[methodName];
174
- if (typeof method === "function") {
175
- const hookResult = (method as () => unknown).call(instance);
176
- if (hookResult instanceof Promise) {
177
- await hookResult;
178
- }
179
- }
180
- }
168
+ for (const methodName of lifecycleMethods(binding, metadataReader, "preDestroy")) {
169
+ const hookResult = callHook(instance, methodName);
170
+ if (hookResult instanceof Promise) {
171
+ await hookResult;
181
172
  }
182
173
  }
183
174
  }
@@ -187,7 +178,7 @@ export class LifecycleManager {
187
178
  const tokenKey = binding.token as Token<unknown> | Constructor;
188
179
 
189
180
  // 1. container-level onDeactivation
190
- const containerHooks = this.#deactivationHooks.get(tokenKey);
181
+ const containerHooks = this.#deactivationHooks?.get(tokenKey);
191
182
  if (containerHooks !== undefined) {
192
183
  for (const hook of containerHooks) {
193
184
  const hookResult = hook(instance);
@@ -206,19 +197,35 @@ export class LifecycleManager {
206
197
  }
207
198
 
208
199
  // 3. @preDestroy()
209
- if (binding.kind === "class") {
210
- const lifecycle = metadataReader.getLifecycleMetadata(binding.target);
211
- if (lifecycle?.preDestroy && lifecycle.preDestroy.length > 0) {
212
- for (const methodName of lifecycle.preDestroy) {
213
- const method = (instance as Record<string, unknown>)[methodName];
214
- if (typeof method === "function") {
215
- const hookResult = (method as () => unknown).call(instance);
216
- if (hookResult instanceof Promise) {
217
- throw new AsyncDeactivationError(tokenDisplayName);
218
- }
219
- }
220
- }
200
+ for (const methodName of lifecycleMethods(binding, metadataReader, "preDestroy")) {
201
+ if (callHook(instance, methodName) instanceof Promise) {
202
+ throw new AsyncDeactivationError(tokenDisplayName);
221
203
  }
222
204
  }
223
205
  }
206
+
207
+ /** Whether the deferred table behind `#activationHooks` has had to be built. */
208
+ get isBuilt(): boolean {
209
+ return this.#activationHooks !== undefined;
210
+ }
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;
224
231
  }