diff --git a/integration/scopes/e2e/transient-scope.spec.ts b/integration/scopes/e2e/transient-scope.spec.ts index ce6a403cb57..3c05ea614de 100644 --- a/integration/scopes/e2e/transient-scope.spec.ts +++ b/integration/scopes/e2e/transient-scope.spec.ts @@ -186,6 +186,81 @@ describe('Transient scope', () => { }); }); + describe('when multiple DEFAULT parents inject the same TRANSIENT -> TRANSIENT chain', () => { + let app: INestApplication; + + @Injectable({ scope: Scope.TRANSIENT }) + class IsolatedNestedTransient { + public static instanceCount = 0; + public readonly instanceId: number; + + constructor() { + IsolatedNestedTransient.instanceCount++; + this.instanceId = IsolatedNestedTransient.instanceCount; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class IsolatedTransientLogger { + public static instanceCount = 0; + public readonly instanceId: number; + + constructor(public readonly nested: IsolatedNestedTransient) { + IsolatedTransientLogger.instanceCount++; + this.instanceId = IsolatedTransientLogger.instanceCount; + } + } + + @Injectable() + class ServiceA { + constructor(public readonly logger: IsolatedTransientLogger) {} + } + + @Injectable() + class ServiceB { + constructor(public readonly logger: IsolatedTransientLogger) {} + } + + before(async () => { + IsolatedNestedTransient.instanceCount = 0; + IsolatedTransientLogger.instanceCount = 0; + + const module = await Test.createTestingModule({ + providers: [ + ServiceA, + ServiceB, + IsolatedTransientLogger, + IsolatedNestedTransient, + ], + }).compile(); + + app = module.createNestApplication(); + await app.init(); + }); + + it('should create separate TransientLogger instances for each DEFAULT parent', () => { + const serviceA = app.get(ServiceA); + const serviceB = app.get(ServiceB); + + expect(serviceA.logger.instanceId).to.not.equal( + serviceB.logger.instanceId, + ); + }); + + it('should create separate nested TRANSIENT instances for each DEFAULT parent', () => { + const serviceA = app.get(ServiceA); + const serviceB = app.get(ServiceB); + + expect(serviceA.logger.nested.instanceId).to.not.equal( + serviceB.logger.nested.instanceId, + ); + }); + + after(async () => { + await app.close(); + }); + }); + describe('when nested transient providers are used in request scope', () => { let server: any; let app: INestApplication; diff --git a/packages/core/injector/injector.ts b/packages/core/injector/injector.ts index 85f5d0204ac..0a25904987e 100644 --- a/packages/core/injector/injector.ts +++ b/packages/core/injector/injector.ts @@ -131,8 +131,9 @@ export class Injector { moduleRef: Module, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper, + inquirerIdOverride?: string, ) { - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = inquirerIdOverride ?? this.getInquirerId(inquirer); const instanceHost = wrapper.getInstanceByContextId( this.getContextId(contextId, wrapper), inquirerId, @@ -179,6 +180,7 @@ export class Injector { targetWrapper, contextId, inquirer, + inquirerId, ); this.applyProperties(instance, properties); wrapper.initTime = this.getNowTimestamp() - t0; @@ -263,6 +265,7 @@ export class Injector { moduleRef: Module, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper, + inquirerIdOverride?: string, ) { const providers = moduleRef.providers; await this.loadInstance( @@ -271,6 +274,7 @@ export class Injector { moduleRef, contextId, inquirer, + inquirerIdOverride, ); await this.loadEnhancersPerContext(wrapper, contextId, wrapper); } @@ -354,15 +358,22 @@ export class Injector { parentInquirer, contextId, ); + const effectiveInquirerId = this.getEffectiveInquirerId( + paramWrapper, + inquirer, + parentInquirer, + contextId, + ); const paramWrapperWithInstance = await this.resolveComponentHost( moduleRef, paramWrapper, contextId, effectiveInquirer, + effectiveInquirerId, ); const instanceHost = paramWrapperWithInstance.getInstanceByContextId( this.getContextId(contextId, paramWrapperWithInstance), - this.getInquirerId(effectiveInquirer), + effectiveInquirerId, ); if (!instanceHost.isResolved && !paramWrapperWithInstance.forwardRef) { isResolved = false; @@ -525,8 +536,9 @@ export class Injector { instanceWrapper: InstanceWrapper>, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper, + inquirerIdOverride?: string, ): Promise { - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = inquirerIdOverride ?? this.getInquirerId(inquirer); const instanceHost = instanceWrapper.getInstanceByContextId( this.getContextId(contextId, instanceWrapper), inquirerId, @@ -539,6 +551,7 @@ export class Injector { instanceWrapper.host ?? moduleRef, contextId, inquirer, + inquirerIdOverride, ); } else if ( !instanceHost.isResolved && @@ -757,18 +770,25 @@ export class Injector { parentInquirer, contextId, ); + const effectivePropertyInquirerId = this.getEffectiveInquirerId( + paramWrapper, + inquirer, + parentInquirer, + contextId, + ); const paramWrapperWithInstance = await this.resolveComponentHost( moduleRef, paramWrapper, contextId, effectivePropertyInquirer, + effectivePropertyInquirerId, ); if (!paramWrapperWithInstance) { return undefined; } const instanceHost = paramWrapperWithInstance.getInstanceByContextId( this.getContextId(contextId, paramWrapperWithInstance), - this.getInquirerId(effectivePropertyInquirer), + effectivePropertyInquirerId, ); return instanceHost.instance; } catch (err) { @@ -822,9 +842,10 @@ export class Injector { targetMetatype: InstanceWrapper, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper, + inquirerIdOverride?: string, ): Promise { const { metatype, inject } = wrapper; - const inquirerId = this.getInquirerId(inquirer); + const inquirerId = inquirerIdOverride ?? this.getInquirerId(inquirer); const instanceHost = targetMetatype.getInstanceByContextId( this.getContextId(contextId, targetMetatype), inquirerId, @@ -920,7 +941,7 @@ export class Injector { ); return hosts.map((item, index) => { const dependency = metadata[index]; - const effectiveInquirer = this.getEffectiveInquirer( + const effectiveInquirerId = this.getEffectiveInquirerId( dependency, inquirer, parentInquirer, @@ -929,7 +950,7 @@ export class Injector { return item?.getInstanceByContextId( this.getContextId(contextId, item), - this.getInquirerId(effectiveInquirer), + effectiveInquirerId, ).instance; }); } @@ -988,6 +1009,42 @@ export class Injector { : inquirer; } + /** + * In STATIC context, transient instances are keyed by inquirer id in the + * transient map. However, the inquirer id is per-class (not per-instance), + * so when multiple DEFAULT-scoped providers inject the same + * TRANSIENT -> TRANSIENT chain, the nested instances are incorrectly shared + * because the intermediate TRANSIENT wrapper has the same id regardless of + * which parent initiated the chain. + * + * This method returns a composite key (parentInquirer.id:inquirer.id) for + * nested TRANSIENT chains in STATIC context, ensuring each resolution path + * produces its own isolated nested instance. + */ + private getEffectiveInquirerId( + dependency: InstanceWrapper | undefined, + inquirer: InstanceWrapper | undefined, + parentInquirer: InstanceWrapper | undefined, + contextId: ContextId, + ): string | undefined { + if ( + contextId === STATIC_CONTEXT && + dependency?.isTransient && + inquirer?.isTransient && + parentInquirer + ) { + return parentInquirer.id + ':' + inquirer.id; + } + // For non-STATIC context, mirror getEffectiveInquirer logic + const effectiveInquirer = this.getEffectiveInquirer( + dependency, + inquirer, + parentInquirer, + contextId, + ); + return this.getInquirerId(effectiveInquirer); + } + private resolveScopedComponentHost( item: InstanceWrapper, contextId: ContextId, @@ -1001,6 +1058,12 @@ export class Injector { item, contextId, this.getEffectiveInquirer(item, inquirer, parentInquirer, contextId), + this.getEffectiveInquirerId( + item, + inquirer, + parentInquirer, + contextId, + ), ); } diff --git a/packages/core/test/injector/nested-transient-isolation.spec.ts b/packages/core/test/injector/nested-transient-isolation.spec.ts index 6078f72eb5f..9344444f926 100644 --- a/packages/core/test/injector/nested-transient-isolation.spec.ts +++ b/packages/core/test/injector/nested-transient-isolation.spec.ts @@ -269,4 +269,109 @@ describe('Nested Transient Isolation', () => { ); }); }); + + describe('when multiple DEFAULT scoped providers inject the same TRANSIENT -> TRANSIENT chain', () => { + @Injectable({ scope: Scope.TRANSIENT }) + class NestedTransientService { + public static instanceCount = 0; + public readonly instanceId: number; + + constructor() { + NestedTransientService.instanceCount++; + this.instanceId = NestedTransientService.instanceCount; + } + } + + @Injectable({ scope: Scope.TRANSIENT }) + class TransientService { + public static instanceCount = 0; + public readonly instanceId: number; + + constructor(public readonly nested: NestedTransientService) { + TransientService.instanceCount++; + this.instanceId = TransientService.instanceCount; + } + } + + @Injectable() + class DefaultParent1 { + constructor(public readonly transient: TransientService) {} + } + + @Injectable() + class DefaultParent2 { + constructor(public readonly transient: TransientService) {} + } + + let nestedTransientWrapper: InstanceWrapper; + let transientWrapper: InstanceWrapper; + let parent1Wrapper: InstanceWrapper; + let parent2Wrapper: InstanceWrapper; + + beforeEach(() => { + NestedTransientService.instanceCount = 0; + TransientService.instanceCount = 0; + + nestedTransientWrapper = new InstanceWrapper({ + name: NestedTransientService.name, + token: NestedTransientService, + metatype: NestedTransientService, + scope: Scope.TRANSIENT, + host: module, + }); + + transientWrapper = new InstanceWrapper({ + name: TransientService.name, + token: TransientService, + metatype: TransientService, + scope: Scope.TRANSIENT, + host: module, + }); + + parent1Wrapper = new InstanceWrapper({ + name: DefaultParent1.name, + token: DefaultParent1, + metatype: DefaultParent1, + scope: Scope.DEFAULT, + host: module, + }); + + parent2Wrapper = new InstanceWrapper({ + name: DefaultParent2.name, + token: DefaultParent2, + metatype: DefaultParent2, + scope: Scope.DEFAULT, + host: module, + }); + + module.providers.set(NestedTransientService, nestedTransientWrapper); + module.providers.set(TransientService, transientWrapper); + module.providers.set(DefaultParent1, parent1Wrapper); + module.providers.set(DefaultParent2, parent2Wrapper); + }); + + it('should create separate TransientService instances for each DEFAULT parent', async () => { + await injector.loadInstance(parent1Wrapper, module.providers, module); + await injector.loadInstance(parent2Wrapper, module.providers, module); + + const parent1Instance = parent1Wrapper.instance; + const parent2Instance = parent2Wrapper.instance; + + expect(parent1Instance.transient.instanceId).to.not.equal( + parent2Instance.transient.instanceId, + ); + }); + + it('should create separate nested TRANSIENT instances for each DEFAULT parent', async () => { + await injector.loadInstance(parent1Wrapper, module.providers, module); + await injector.loadInstance(parent2Wrapper, module.providers, module); + + const parent1Instance = parent1Wrapper.instance; + const parent2Instance = parent2Wrapper.instance; + + expect(parent1Instance.transient.nested.instanceId).to.not.equal( + parent2Instance.transient.nested.instanceId, + ); + }); + }); }); diff --git a/packages/testing/testing-injector.ts b/packages/testing/testing-injector.ts index c5a783196b5..deba7761bb9 100644 --- a/packages/testing/testing-injector.ts +++ b/packages/testing/testing-injector.ts @@ -53,6 +53,7 @@ export class TestingInjector extends Injector { instanceWrapper: InstanceWrapper, contextId = STATIC_CONTEXT, inquirer?: InstanceWrapper, + inquirerIdOverride?: string, ): Promise { try { const existingProviderWrapper = await super.resolveComponentHost( @@ -60,6 +61,7 @@ export class TestingInjector extends Injector { instanceWrapper, contextId, inquirer, + inquirerIdOverride, ); return existingProviderWrapper; } catch (err) {