From 1bd77a60111e10d261f9370608e0e635fe7c5831 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sat, 14 Feb 2026 04:50:03 -0800 Subject: [PATCH] fix(core): isolate nested transient instances in static context When multiple DEFAULT-scoped providers inject the same TRANSIENT chain (e.g., ServiceA -> TransientLogger -> NestedTransient and ServiceB -> TransientLogger -> NestedTransient), the nested transient instances were incorrectly shared because the transient map key (inquirerId) is per-class, not per-instance. In STATIC context, getEffectiveInquirer returned the intermediate TRANSIENT wrapper's id, which is the same regardless of which DEFAULT parent initiated the chain. This fix introduces a composite key (parentInquirer.id:inquirer.id) for nested TRANSIENT chains in STATIC context via getEffectiveInquirerId(). The composite key differentiates resolution paths so each DEFAULT parent gets its own isolated nested transient instances. Also updates TestingInjector.resolveComponentHost() to forward the new inquirerIdOverride parameter to the parent class. Closes #16257 --- .../scopes/e2e/transient-scope.spec.ts | 75 +++++++++++++ packages/core/injector/injector.ts | 77 +++++++++++-- .../nested-transient-isolation.spec.ts | 105 ++++++++++++++++++ packages/testing/testing-injector.ts | 2 + 4 files changed, 252 insertions(+), 7 deletions(-) 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) {