-
-
Notifications
You must be signed in to change notification settings - Fork 8k
fix(core): skip lifecycle hooks for non-instantiated transient services #15571
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(core): skip lifecycle hooks for non-instantiated transient services #15571
Conversation
Pull Request Test Coverage Report for Build 2171e1b3-d8d9-4560-88c0-60cf552b1da6Details
💛 - Coveralls |
The WebSocket test failure seems to be a flaky test (connection cleanup timing issue). |
This change does not affect the behavior. The In the instantiateClass method, the decision to call a constructor or not is sophisticated, but still insufficient to handle deep transient cases such as |
Thank you @albert-mirzoyan for the insightful review! You're absolutely right. The issue is that I've updated the fix to check
I think This handles all scenarios including complex dependency chains like singleton → transient → transient. |
Sadly, this |
@albert-mirzoyan
This explains why lifecycle hooks are still being called on incomplete transient instances in complex scenarios like singleton → transient → transient. The issue is indeed deeper than initially thought. A few potential approaches:
cc @kamilmysliwiec @micalevisk - would appreciate your input on the preferred approach here. Should we pursue one of these solutions or investigate further? |
Should be as easy as adding a new attribute to the |
@kamilmysliwiec |
Thank you @mag123c With the The The issue might be in |
@albert-mirzoyan describe('Nested Transient Services', () => {
it('should properly instantiate all nested transient services in singleton -> transient -> deepTransient chain', async () => {
const constructorCalls: string[] = [];
const onModuleInitCalls: string[] = [];
// Deep transient service (innermost)
@Injectable({ scope: Scope.TRANSIENT })
class DeepTransientService implements OnModuleInit {
public value = 'deep-value';
constructor() {
constructorCalls.push('DeepTransientService');
}
onModuleInit() {
onModuleInitCalls.push('DeepTransientService');
}
getValue() {
return this.value;
}
}
// Middle transient service
@Injectable({ scope: Scope.TRANSIENT })
class TransientService implements OnModuleInit {
constructor(public deepService: DeepTransientService) {
constructorCalls.push('TransientService');
}
onModuleInit() {
onModuleInitCalls.push('TransientService');
}
}
// Singleton service (outermost)
@Injectable()
class SingletonService implements OnModuleInit {
constructor(public transientService: TransientService) {
constructorCalls.push('SingletonService');
}
onModuleInit() {
onModuleInitCalls.push('SingletonService');
}
}
@Module({
providers: [SingletonService, TransientService, DeepTransientService],
})
class TestModule {}
const moduleRef = await Test.createTestingModule({
providers: [SingletonService, TransientService, DeepTransientService],
}).compile();
await moduleRef.init();
const singletonService = moduleRef.get(SingletonService);
// Verify all constructors were called
expect(constructorCalls).to.include('DeepTransientService');
expect(constructorCalls).to.include('TransientService');
expect(constructorCalls).to.include('SingletonService');
// Verify services are properly instantiated and connected
expect(singletonService).to.exist;
expect(singletonService.transientService).to.exist;
expect(singletonService.transientService.deepService).to.exist;
// Verify the deep service is functional (not just a prototype object)
expect(singletonService.transientService.deepService.getValue()).to.equal(
'deep-value',
);
expect(
singletonService.transientService.deepService.constructor.name,
).to.equal('DeepTransientService');
// Verify lifecycle hooks were called appropriately
// With our fix, transient services in this chain should still have their hooks called
// when they are properly instantiated
expect(onModuleInitCalls).to.include('SingletonService');
await moduleRef.close();
});
it('should not call lifecycle hooks on prototype-only transient instances', async () => {
const onModuleInitCalls: string[] = [];
@Injectable({ scope: Scope.TRANSIENT })
class TransientService implements OnModuleInit {
onModuleInit() {
onModuleInitCalls.push('TransientService');
}
}
// This service won't be instantiated during bootstrap
@Injectable({ scope: Scope.REQUEST })
class RequestScopedService {
constructor(private transientService: TransientService) {}
}
@Module({
providers: [TransientService, RequestScopedService],
})
class TestModule {}
const moduleRef = await Test.createTestingModule({
providers: [TransientService, RequestScopedService],
}).compile();
await moduleRef.init();
// The transient service should not have its lifecycle hook called
// because it wasn't actually instantiated (only request-scoped service depends on it)
expect(onModuleInitCalls).to.not.include('TransientService');
await moduleRef.close();
});
}); The test passes, showing that DeepTransientService is properly instantiated. The However, I haven't extensively used transient services in production, so there might be edge cases I'm not considering. If you have a specific scenario where the DeepTransientService isn't being instantiated properly, could you share a reproduction? That would help me understand what I might be missing. Is this the singleton → transient → transient pattern you were referring to, or is there a different configuration I should test? |
@mag123c import { Controller, Injectable, Module, OnModuleInit, Scope } from "@nestjs/common";
import { Test } from "@nestjs/testing";
describe('Nested Transient Services', () => {
it('should properly instantiate all nested transient services in singleton -> transient -> deepTransient chain', async () => {
const constructorCalls: string[] = [];
const onModuleInitCalls: string[] = [];
// Deep transient service (innermost)
@Injectable({ scope: Scope.TRANSIENT })
class DeepTransientService implements OnModuleInit {
public value = 'deep-value';
constructor() {
constructorCalls.push('DeepTransientService');
}
onModuleInit() {
onModuleInitCalls.push('DeepTransientService');
}
getValue() {
return this.value;
}
}
// Middle transient service
@Injectable({ scope: Scope.TRANSIENT })
class TransientService implements OnModuleInit {
constructor(public deepService: DeepTransientService) {
constructorCalls.push('TransientService');
}
onModuleInit() {
onModuleInitCalls.push('TransientService');
}
}
// Singleton service (outermost)
@Controller()
class SingletonController implements OnModuleInit {
constructor(public transientService: TransientService) {
constructorCalls.push('SingletonService');
}
onModuleInit() {
onModuleInitCalls.push('SingletonService');
}
}
@Module({
controllers: [SingletonController],
providers: [TransientService, DeepTransientService],
})
class TestModule {}
const moduleRef = await Test.createTestingModule({
controllers: [SingletonController],
providers: [TransientService, DeepTransientService],
}).compile();
await moduleRef.init();
const singletonService = moduleRef.get(SingletonController);
// Verify all constructors were called
expect(constructorCalls).toContain('DeepTransientService');
expect(constructorCalls).toContain('TransientService');
expect(constructorCalls).toContain('SingletonController');
// Verify services are properly instantiated and connected
expect(singletonService).toBeDefined();
expect(singletonService.transientService).toBeDefined();
expect(singletonService.transientService.deepService).toBeDefined();
// Verify the deep service is functional (not just a prototype object)
expect(singletonService.transientService.deepService.getValue()).toBe(
'deep-value',
);
expect(
singletonService.transientService.deepService.constructor.name,
).toBe('DeepTransientService');
// Verify lifecycle hooks were called appropriately
// With our fix, transient services in this chain should still have their hooks called
// when they are properly instantiated
expect(onModuleInitCalls).toContain('SingletonService');
await moduleRef.close();
});
}); |
@albert-mirzoyan |
Not 100% sure but I believe this has already been addressed here #15469 |
PR Checklist
Please check if your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
When transient services are injected into request-scoped controllers, the
onModuleInit()
lifecycle hook is called during application bootstrap even though the service instance has not been created yet. This results in:Issue Number: #15553
What is the new behavior?
The fix adds an instance existence check before returning transient instances for lifecycle hook processing. Now:
The change is minimal and defensive - it simply adds
&& !!item.instance
to the existing filter condition ingetStaticTransientInstances()
.Does this PR introduce a breaking change?
Other information
Tests were not added because: