Skip to content

Commit 910f727

Browse files
fix(signals): replace memory leak warning with deprecation for calls outside injection context
Instead of warning only when the source injector is root (which uses private Angular APIs and misses cases like child→parent leaks), warn for all reactive calls outside an injection context without an explicit injector. This aligns with Angular's pattern for effect(), linkedSignal(), and resource(), and avoids reliance on internal R3Injector.scopes API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da5b706 commit 910f727

File tree

6 files changed

+86
-112
lines changed

6 files changed

+86
-112
lines changed

modules/signals/rxjs-interop/spec/rx-method.spec.ts

Lines changed: 36 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -378,72 +378,71 @@ describe('rxMethod', () => {
378378
});
379379
});
380380

381-
describe('warning on source injector', () => {
381+
describe('deprecation warning for reactive calls outside injection context', () => {
382382
const warnSpy = vitest.spyOn(console, 'warn');
383383
warnSpy.mockImplementation(() => void true);
384384

385385
beforeEach(() => {
386386
warnSpy.mockClear();
387387
});
388388

389-
const createAdder = (callback: (value: number) => void) =>
390-
TestBed.runInInjectionContext(() => rxMethod<number>(tap(callback)));
389+
const createMethod = () =>
390+
TestBed.runInInjectionContext(() => rxMethod<number>(tap(() => void 0)));
391391

392-
it('does not warn on non-reactive value and source injector', () => {
393-
let a = 1;
394-
const adder = createAdder((value) => (a += value));
395-
adder(1);
392+
it('does not warn on non-reactive value', () => {
393+
const method = createMethod();
394+
method(1);
396395

397396
expect(warnSpy).not.toHaveBeenCalled();
398397
});
399398

400-
for (const [reactiveValue, name] of [
401-
[signal(1), 'Signal'],
402-
[of(1), 'Observable'],
399+
for (const [createReactiveValue, name] of [
400+
[() => signal(1), 'Signal'],
401+
[() => of(1), 'Observable'],
403402
] as const) {
404403
describe(`${name}`, () => {
405-
it('warns when source injector is root', () => {
406-
let a = 1;
407-
const adder = createAdder((value) => (a += value));
408-
adder(reactiveValue);
404+
it('warns when called outside of an injection context', () => {
405+
const method = createMethod();
406+
method(createReactiveValue());
409407

410408
expect(warnSpy).toHaveBeenCalled();
411-
const warning = (warnSpy.mock.lastCall || []).join(' ');
412-
expect(warning).toMatch(
413-
/reactive method was called outside the injection context with a signal or observable/
414-
);
415409
});
416410

417-
it('does not warn when source injector is not root', () => {
418-
let a = 1;
419-
const childInjector = createEnvironmentInjector(
420-
[],
421-
TestBed.inject(EnvironmentInjector)
422-
);
423-
const adder = runInInjectionContext(childInjector, () =>
424-
rxMethod<number>(tap((value) => (a += value)))
425-
);
426-
adder(reactiveValue);
411+
it('does not warn when explicit injector is provided', () => {
412+
const method = createMethod();
413+
const injector = TestBed.inject(Injector);
414+
method(createReactiveValue(), { injector });
427415

428416
expect(warnSpy).not.toHaveBeenCalled();
429417
});
430418

431-
it('does not warn on manual injector', () => {
432-
let a = 1;
433-
const adder = createAdder((value) => (a += value));
434-
const injector = TestBed.inject(Injector);
435-
adder(reactiveValue, { injector });
419+
it('does not warn when called within an injection context', () => {
420+
const method = createMethod();
421+
TestBed.runInInjectionContext(() => method(createReactiveValue()));
436422

437423
expect(warnSpy).not.toHaveBeenCalled();
438424
});
439425

440-
it('does not warn if called within injection context', () => {
441-
let a = 1;
442-
const adder = createAdder((value) => (a += value));
443-
TestBed.runInInjectionContext(() => adder(reactiveValue));
426+
it('does not warn when called within a child injection context', () => {
427+
const method = createMethod();
428+
const childInjector = createEnvironmentInjector(
429+
[],
430+
TestBed.inject(EnvironmentInjector)
431+
);
432+
runInInjectionContext(childInjector, () =>
433+
method(createReactiveValue())
434+
);
444435

445436
expect(warnSpy).not.toHaveBeenCalled();
446437
});
438+
439+
it('includes deprecation message', () => {
440+
const method = createMethod();
441+
method(createReactiveValue());
442+
443+
const warning = (warnSpy.mock.lastCall || []).join(' ');
444+
expect(warning).toMatch(/deprecated/);
445+
});
447446
});
448447
}
449448
});

modules/signals/rxjs-interop/src/rx-method.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -80,26 +80,27 @@ export function rxMethod<Input>(
8080
}
8181

8282
const callerInjector = getCallerInjector();
83-
const instanceInjector =
84-
config?.injector ?? callerInjector ?? sourceInjector;
8583

8684
if (
8785
typeof ngDevMode !== 'undefined' &&
8886
ngDevMode &&
8987
config?.injector === undefined &&
90-
callerInjector === undefined &&
91-
isRootInjector(sourceInjector)
88+
callerInjector === undefined
9289
) {
9390
console.warn(
94-
'@ngrx/signals/rxjs-interop: The reactive method was called outside',
95-
'the injection context with a signal or observable. This may lead to',
96-
'a memory leak. Make sure to call it within the injection context',
91+
'@ngrx/signals/rxjs-interop: Calling a reactive method outside of',
92+
'an injection context with a signal or observable is deprecated.',
93+
'In a future version, this will throw an error.',
94+
'Either call it within an injection context',
9795
'(e.g. in a constructor or field initializer) or pass an injector',
9896
'explicitly via the config parameter.\n\nFor more information, see:',
9997
'https://ngrx.io/guide/signals/rxjs-integration#reactive-methods-and-injector-hierarchies'
10098
);
10199
}
102100

101+
const instanceInjector =
102+
config?.injector ?? callerInjector ?? sourceInjector;
103+
103104
if (typeof input === 'function') {
104105
const watcher = effect(
105106
() => {
@@ -140,15 +141,3 @@ function getCallerInjector(): Injector | undefined {
140141
return undefined;
141142
}
142143
}
143-
144-
/**
145-
* Checks whether the given injector is a root or platform injector.
146-
*
147-
* Uses the `scopes` property from Angular's `R3Injector` (the concrete
148-
* `EnvironmentInjector` implementation) via duck typing. This is an
149-
* internal Angular API that may change in future versions.
150-
*/
151-
function isRootInjector(injector: Injector): boolean {
152-
const scopes: Set<string> | undefined = (injector as any)['scopes'];
153-
return scopes?.has('root') === true || scopes?.has('platform') === true;
154-
}

modules/signals/spec/signal-method.spec.ts

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ describe('signalMethod', () => {
239239
expect(a).toBe(104);
240240
});
241241

242-
describe('warns on source injector', () => {
242+
describe('deprecation warning for reactive calls outside injection context', () => {
243243
const warnSpy = vi.spyOn(console, 'warn');
244244
warnSpy.mockImplementation(() => void true);
245245
const n = signal(1);
@@ -248,55 +248,52 @@ describe('signalMethod', () => {
248248
warnSpy.mockClear();
249249
});
250250

251-
it('warns when source injector is root', () => {
252-
let a = 1;
253-
const adder = createAdder((value) => (a += value));
251+
it('warns when called outside of an injection context', () => {
252+
const adder = createAdder((value) => value);
254253
adder(n);
255254

256255
expect(warnSpy).toHaveBeenCalled();
257-
const warning = (warnSpy.mock.lastCall || []).join(' ');
258-
expect(warning).toMatch(
259-
/function returned by signalMethod was called outside the injection context with a signal/
260-
);
261256
});
262257

263-
it('does not warn when source injector is not root', () => {
264-
let a = 1;
265-
const childInjector = createEnvironmentInjector(
266-
[],
267-
TestBed.inject(EnvironmentInjector)
268-
);
269-
const adder = runInInjectionContext(childInjector, () =>
270-
signalMethod<number>((value) => (a += value))
271-
);
272-
adder(n);
273-
274-
expect(warnSpy).not.toHaveBeenCalled();
275-
});
276-
277-
it('does not warn on non-reactive value and source injector', () => {
278-
let a = 1;
279-
const adder = createAdder((value) => (a += value));
258+
it('does not warn on non-reactive value', () => {
259+
const adder = createAdder((value) => value);
280260
adder(1);
281261

282262
expect(warnSpy).not.toHaveBeenCalled();
283263
});
284264

285-
it('does not warn on manual injector', () => {
286-
let a = 1;
287-
const adder = createAdder((value) => (a += value));
265+
it('does not warn when explicit injector is provided', () => {
266+
const adder = createAdder((value) => value);
288267
const injector = TestBed.inject(Injector);
289268
adder(n, { injector });
290269

291270
expect(warnSpy).not.toHaveBeenCalled();
292271
});
293272

294-
it('does not warn if called within injection context', () => {
295-
let a = 1;
296-
const adder = createAdder((value) => (a += value));
273+
it('does not warn when called within an injection context', () => {
274+
const adder = createAdder((value) => value);
297275
TestBed.runInInjectionContext(() => adder(n));
298276

299277
expect(warnSpy).not.toHaveBeenCalled();
300278
});
279+
280+
it('does not warn when called within a child injection context', () => {
281+
const adder = createAdder((value) => value);
282+
const childInjector = createEnvironmentInjector(
283+
[],
284+
TestBed.inject(EnvironmentInjector)
285+
);
286+
runInInjectionContext(childInjector, () => adder(n));
287+
288+
expect(warnSpy).not.toHaveBeenCalled();
289+
});
290+
291+
it('includes deprecation message', () => {
292+
const adder = createAdder((value) => value);
293+
adder(n);
294+
295+
const warning = (warnSpy.mock.lastCall || []).join(' ');
296+
expect(warning).toMatch(/deprecated/);
297+
});
301298
});
302299
});

modules/signals/src/signal-method.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,26 +59,27 @@ export function signalMethod<Input>(
5959
): EffectRef => {
6060
if (isReactiveComputation(input)) {
6161
const callerInjector = getCallerInjector();
62-
const instanceInjector =
63-
config?.injector ?? callerInjector ?? sourceInjector;
6462

6563
if (
6664
typeof ngDevMode !== 'undefined' &&
6765
ngDevMode &&
6866
config?.injector === undefined &&
69-
callerInjector === undefined &&
70-
isRootInjector(sourceInjector)
67+
callerInjector === undefined
7168
) {
7269
console.warn(
73-
'@ngrx/signals: The function returned by signalMethod was called',
74-
'outside the injection context with a signal. This may lead to',
75-
'a memory leak. Make sure to call it within the injection context',
76-
'(e.g. in a constructor or field initializer) or pass an injector',
77-
'explicitly via the config parameter.\n\nFor more information, see:',
70+
'@ngrx/signals: Calling signalMethod outside of an injection',
71+
'context with a signal is deprecated. In a future version,',
72+
'this will throw an error. Either call it within an injection',
73+
'context (e.g. in a constructor or field initializer) or pass',
74+
'an injector explicitly via the config parameter.',
75+
'\n\nFor more information, see:',
7876
'https://ngrx.io/guide/signals/signal-method#automatic-cleanup'
7977
);
8078
}
8179

80+
const instanceInjector =
81+
config?.injector ?? callerInjector ?? sourceInjector;
82+
8283
const watcher = effect(
8384
() => {
8485
const value = input();
@@ -119,15 +120,3 @@ function getCallerInjector(): Injector | undefined {
119120
function isReactiveComputation<T>(value: T | (() => T)): value is () => T {
120121
return typeof value === 'function';
121122
}
122-
123-
/**
124-
* Checks whether the given injector is a root or platform injector.
125-
*
126-
* Uses the `scopes` property from Angular's `R3Injector` (the concrete
127-
* `EnvironmentInjector` implementation) via duck typing. This is an
128-
* internal Angular API that may change in future versions.
129-
*/
130-
function isRootInjector(injector: Injector): boolean {
131-
const scopes: Set<string> | undefined = (injector as any)['scopes'];
132-
return scopes?.has('root') === true || scopes?.has('platform') === true;
133-
}

projects/www/src/app/pages/guide/signals/rxjs-integration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,9 @@ export class Numbers implements OnInit {
297297
}
298298
```
299299

300-
<ngrx-docs-alert type="inform">
300+
<ngrx-docs-alert type="warn">
301301

302-
If the reactive method is initialized in a root-scoped injector (e.g. a service with `providedIn: 'root'`) and called with a signal, a computation function, or an observable outside the injection context without providing an injector, a warning message about a potential memory leak is displayed in development mode. This warning does not apply when the reactive method is initialized in a non-root injector (e.g. a component or a service provided at the component level), since cleanup is handled by the source injector's lifecycle.
302+
Calling a reactive method with a signal, a computation function, or an observable outside of an injection context without providing an explicit injector is deprecated. In a future version, this will throw an error. Either call it within an injection context (e.g. in a constructor or field initializer) or pass an injector explicitly via the config parameter.
303303

304304
</ngrx-docs-alert>
305305

projects/www/src/app/pages/guide/signals/signal-method.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ export class Numbers implements OnInit {
126126

127127
Here, the `effect` used internally by `signalMethod` outlives the component, which would produce a memory leak.
128128

129-
<ngrx-docs-alert type="inform">
129+
<ngrx-docs-alert type="warn">
130130

131-
If `signalMethod` is initialized in a root-scoped injector (e.g. a service with `providedIn: 'root'`) and called with a signal outside the injection context without providing an injector, a warning message about a potential memory leak is displayed in development mode. This warning does not apply when `signalMethod` is initialized in a non-root injector (e.g. a component or a service provided at the component level), since cleanup is handled by the source injector's lifecycle.
131+
Calling `signalMethod` with a signal or a computation function outside of an injection context without providing an explicit injector is deprecated. In a future version, this will throw an error. Either call it within an injection context (e.g. in a constructor or field initializer) or pass an injector explicitly via the config parameter.
132132

133133
</ngrx-docs-alert>
134134

0 commit comments

Comments
 (0)