Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions integration/scopes/e2e/transient-scope.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 70 additions & 7 deletions packages/core/injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +180,7 @@ export class Injector {
targetWrapper,
contextId,
inquirer,
inquirerId,
);
this.applyProperties(instance, properties);
wrapper.initTime = this.getNowTimestamp() - t0;
Expand Down Expand Up @@ -263,6 +265,7 @@ export class Injector {
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
inquirerIdOverride?: string,
) {
const providers = moduleRef.providers;
await this.loadInstance<Injectable>(
Expand All @@ -271,6 +274,7 @@ export class Injector {
moduleRef,
contextId,
inquirer,
inquirerIdOverride,
);
await this.loadEnhancersPerContext(wrapper, contextId, wrapper);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -525,8 +536,9 @@ export class Injector {
instanceWrapper: InstanceWrapper<T | Promise<T>>,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
inquirerIdOverride?: string,
): Promise<InstanceWrapper> {
const inquirerId = this.getInquirerId(inquirer);
const inquirerId = inquirerIdOverride ?? this.getInquirerId(inquirer);
const instanceHost = instanceWrapper.getInstanceByContextId(
this.getContextId(contextId, instanceWrapper),
inquirerId,
Expand All @@ -539,6 +551,7 @@ export class Injector {
instanceWrapper.host ?? moduleRef,
contextId,
inquirer,
inquirerIdOverride,
);
} else if (
!instanceHost.isResolved &&
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -822,9 +842,10 @@ export class Injector {
targetMetatype: InstanceWrapper,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
inquirerIdOverride?: string,
): Promise<T> {
const { metatype, inject } = wrapper;
const inquirerId = this.getInquirerId(inquirer);
const inquirerId = inquirerIdOverride ?? this.getInquirerId(inquirer);
const instanceHost = targetMetatype.getInstanceByContextId(
this.getContextId(contextId, targetMetatype),
inquirerId,
Expand Down Expand Up @@ -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,
Expand All @@ -929,7 +950,7 @@ export class Injector {

return item?.getInstanceByContextId(
this.getContextId(contextId, item),
this.getInquirerId(effectiveInquirer),
effectiveInquirerId,
).instance;
});
}
Expand Down Expand Up @@ -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,
Expand All @@ -1001,6 +1058,12 @@ export class Injector {
item,
contextId,
this.getEffectiveInquirer(item, inquirer, parentInquirer, contextId),
this.getEffectiveInquirerId(
item,
inquirer,
parentInquirer,
contextId,
),
);
}

Expand Down
105 changes: 105 additions & 0 deletions packages/core/test/injector/nested-transient-isolation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
});
2 changes: 2 additions & 0 deletions packages/testing/testing-injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ export class TestingInjector extends Injector {
instanceWrapper: InstanceWrapper<T>,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
inquirerIdOverride?: string,
): Promise<InstanceWrapper> {
try {
const existingProviderWrapper = await super.resolveComponentHost(
moduleRef,
instanceWrapper,
contextId,
inquirer,
inquirerIdOverride,
);
return existingProviderWrapper;
} catch (err) {
Expand Down