diff --git a/packages/core/injector/injector.ts b/packages/core/injector/injector.ts index 1abd98d186c..5b20f358dd2 100644 --- a/packages/core/injector/injector.ts +++ b/packages/core/injector/injector.ts @@ -835,12 +835,14 @@ export class Injector { : new (metatype as Type)(...instances); instanceHost.instance = this.instanceDecorator(instanceHost.instance); + instanceHost.isConstructorCalled = true; } else if (isInContext) { const factoryReturnValue = (targetMetatype.metatype as any as Function)( ...instances, ); instanceHost.instance = await factoryReturnValue; instanceHost.instance = this.instanceDecorator(instanceHost.instance); + instanceHost.isConstructorCalled = true; } instanceHost.isResolved = true; return instanceHost.instance; diff --git a/packages/core/injector/instance-wrapper.ts b/packages/core/injector/instance-wrapper.ts index 890199ee332..f89b04cc44a 100644 --- a/packages/core/injector/instance-wrapper.ts +++ b/packages/core/injector/instance-wrapper.ts @@ -44,6 +44,7 @@ export interface InstancePerContext { isResolved?: boolean; isPending?: boolean; donePromise?: Promise; + isConstructorCalled?: boolean; } export interface PropertyMetadata { @@ -439,7 +440,11 @@ export class InstanceWrapper { const instances = [...this.transientMap.values()]; return iterate(instances) .map(item => item.get(STATIC_CONTEXT)) - .filter(item => !!item) + .filter(item => { + // Only return items where constructor has been actually called + // This prevents calling lifecycle hooks on non-instantiated transient services + return !!(item && item.isConstructorCalled); + }) .toArray(); } diff --git a/packages/core/test/injector/instance-wrapper.spec.ts b/packages/core/test/injector/instance-wrapper.spec.ts index 9516ed584ed..1f986d1978e 100644 --- a/packages/core/test/injector/instance-wrapper.spec.ts +++ b/packages/core/test/injector/instance-wrapper.spec.ts @@ -896,16 +896,64 @@ describe('InstanceWrapper', () => { }); }); describe('when instance is transient', () => { - it('should return all static instances', () => { + it('should return instances where constructor was called', () => { const wrapper = new InstanceWrapper({ scope: Scope.TRANSIENT, }); const instanceHost = { instance: {}, + isConstructorCalled: true, }; wrapper.setInstanceByInquirerId(STATIC_CONTEXT, 'test', instanceHost); expect(wrapper.getStaticTransientInstances()).to.be.eql([instanceHost]); }); + + describe('lifecycle hooks on transient services', () => { + // Tests for issue #15553: prevent lifecycle hooks on non-instantiated transient services + it('should filter out instances created with Object.create (prototype only)', () => { + const wrapper = new InstanceWrapper({ + scope: Scope.TRANSIENT, + }); + // Simulates what happens in cloneTransientInstance + const prototypeOnlyInstance = { + instance: Object.create({}), + isResolved: true, // This is set to true incorrectly in injector.ts + isConstructorCalled: false, // But constructor was never called + }; + wrapper.setInstanceByInquirerId( + STATIC_CONTEXT, + 'inquirer', + prototypeOnlyInstance, + ); + + // Should not include this instance for lifecycle hooks + expect(wrapper.getStaticTransientInstances()).to.be.eql([]); + }); + + it('should include instances where constructor was actually invoked', () => { + class TestService {} + const wrapper = new InstanceWrapper({ + scope: Scope.TRANSIENT, + metatype: TestService, + }); + // Simulates what happens after instantiateClass + const properInstance = { + instance: new TestService(), + isResolved: true, + isConstructorCalled: true, + }; + wrapper.setInstanceByInquirerId( + STATIC_CONTEXT, + 'inquirer', + properInstance, + ); + + // Should include this instance for lifecycle hooks + expect(wrapper.getStaticTransientInstances()).to.be.eql([ + properInstance, + ]); + }); + }); }); });