8000 fix(common): abort HTTP requests once application is destroyed by arturovt · Pull Request #59575 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

fix(common): abort HTTP requests once application is destroyed #59575

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
fix(common): abort HTTP requests once application is destroyed
In this commit, we abort any pending HTTP requests when the application is destroyed to prevent memory leaks and the execution of operations that should not occur once resources are released. For example, if an HTTP response is returned after the application is destroyed and someone calls `runInInjectionContext`, it would throw an error indicating that the injector has already been destroyed. This ensures a graceful cleanup, ensuring that no requests proceed once the root injector is destroyed.
  • Loading branch information
arturovt committed Jun 5, 2025
commit 2e8acd1c93de9b6a7bc35fcf7506056c9e2f2283
2 changes: 2 additions & 0 deletions goldens/public-api/common/http/errors.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
HEADERS_ALTERED_BY_TRANSFER_CACHE = 2802,
// (undocumented)
HTTP_CLIENT_MISSING_INJECTION_CONTEXT = 2814,
// (undocumented)
HTTP_ORIGIN_MAP_CONTAINS_PATH = 2804,
// (undocumented)
HTTP_ORIGIN_MAP_USED_IN_CLIENT = 2803,
Expand Down
33 changes: 29 additions & 4 deletions packages/common/http/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Injectable, ɵRuntimeError as RuntimeError} from '@angular/core';
import {Observable, of} from 'rxjs';
import {concatMap, filter, map} from 'rxjs/operators';
import {
DestroyRef,
inject,
Injectable,
ɵRuntimeError as RuntimeError,
ɵisInInjectionContext as isInInjectionContext,
ɵformatRuntimeError as formatRuntimeError,
} from '@angular/core';
import {type Observable, of, Subject} from 'rxjs';
import {concatMap, filter, map, takeUntil} from 'rxjs/operators';

import {HttpHandler} from './backend';
import {HttpContext} from './context';
Expand Down Expand Up @@ -113,7 +120,24 @@ function addBody<T>(
*/
@Injectable()
export class HttpClient {
constructor(private handler: HttpHandler) {}
private readonly destroyed$ = new Subject<void>();

constructor(private handler: HttpHandler) {
// Attempt to retrieve a `DestroyRef` optionally.
// For backwards compatibility reasons, this cannot be required.
if (isInInjectionContext()) {
inject(DestroyRef, {optional: true})?.onDestroy(() => this.destroyed$.next());
} else if (typeof ngDevMode !== 'undefined' && ngDevMode) {
console.warn(
formatRuntimeError(
RuntimeErrorCode.HTTP_CLIENT_MISSING_INJECTION_CONTEXT,
'Angular detected that HttpClient is being instantiated outside of an injection context. ' +
'As a result, automatic cleanup via DestroyRef will not be performed. ' +
'T 10000 his may lead to memory leaks.',
),
);
}
}

/**
* Sends an `HttpRequest` and returns a stream of `HttpEvent`s.
Expand Down Expand Up @@ -681,6 +705,7 @@ export class HttpClient {
// subscription (this also makes retries re-run the handler, including interceptors).
const events$: Observable<HttpEvent<any>> = of(req).pipe(
concatMap((req: HttpRequest<any>) => this.handler.handle(req)),
takeUntil(this.destroyed$),
);

// If coming via the API signature which accepts a previously constructed HttpRequest,
Expand Down
1 change: 1 addition & 0 deletions packages/common/http/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export const enum RuntimeErrorCode {
JSONP_WRONG_RESPONSE_TYPE = 2811,
JSONP_HEADERS_NOT_SUPPORTED = 2812,
KEEPALIVE_NOT_SUPPORTED_WITH_XHR = 2813,
HTTP_CLIENT_MISSING_INJECTION_CONTEXT = 2814,
}
38 changes: 38 additions & 0 deletions packages/common/http/test/fetch_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,44 @@ describe('FetchBackend', async () => {
fetchMock.mockFlush(HttpStatusCode.Ok, 'OK');
});

describe('root injector is destroyed', () => {
it('aborts a request once root injector is destroyed', async () => {
const assertion = {
abortHappened: false,
error: <HttpErrorResponse | null>null,
};

const request = new HttpRequest('GET', '/test');
backend.handle(request).subscribe({
error: (error: HttpErrorResponse) => {
assertion.abortHappened = true;
assertion.error = error;
},
});

expect(assertion).toEqual({abortHappened: false, error: null});

// We still need to manually reject the promise because we are in a unit test environment.
// However, the unit test ensures that `abort()` is called on the abort controller
// when the root injector is destroyed.
// The `mockAbortEvent` is unrelated to abort controllers;
// it is only responsible for rejecting a promise.
fetchMock.mockAbortEvent();

// `resetTestingModule` triggers the destruction of the root injector.
// We cannot inject the `ApplicationRef` and invoke `destroy()` because
// it would attempt to call `resetTestingModule()` and throw an error
// indicating that the injector has already been destroyed.
TestBed.resetTestingModule();

// Wait until a promise is rejected.
await Promise.resolve();

expect(assertion.abortHappened).toEqual(true);
expect(assertion.error!.error).toBeInstanceOf(DOMException);
});
});

describe('progress events', () => {
it('are emitted for download progress', (done) => {
backend
Expand Down
38 changes: 37 additions & 1 deletion packages/common/http/test/provider_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
Provider,
} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {EMPTY, Observable, from} from 'rxjs';< 8000 /td>
import {EMPTY, Observable, finalize, from, mergeMap, timer} from 'rxjs';

import {HttpInterceptorFn, resetFetchBackendWarningFlag} from '../src/interceptor';
import {
Expand Down Expand Up @@ -565,6 +565,42 @@ describe('provideHttpClient', () => {
globalThis['ngServerMode'] = undefined;
});
});

describe('root injector is destroyed', () => {
it('aborts a request once root injector is destroyed', () => {
const recorder: string[] = [];

const interceptorFn: HttpInterceptorFn = (req, next) => {
recorder.push('interceptorFn()');
return timer(3000).pipe(
mergeMap(() => next(req)),
finalize(() => recorder.push('interceptorFn finalize()')),
);
};

TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([interceptorFn])),
provideHttpClientTesting(),
],
});

TestBed.inject(HttpClient).get('/test', {responseType: 'text'}).subscribe();

expect(recorder).toEqual(['interceptorFn()']);

// `resetTestingModule` triggers the destruction of the root injector.
// We cannot inject the `ApplicationRef` and invoke `destroy()` because
// it would attempt to call `resetTestingModule()` and throw an error
// indicating that the injector has already been destroyed.
TestBed.resetTestingModule();

// Note: Prior to adding `takeUntil()` within `HttpClient`, it did not
// unsubscribe when the root injector was destroyed, and the recorder
// did not include a `finalize` function call.
expect(recorder).toEqual(['interceptorFn()', 'interceptorFn finalize()']);
});
});
});

function setXsrfToken(token: string): void {
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/di/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
*/

export * from './metadata';
export {assertInInjectionContext, runInInjectionContext} from './contextual';
export {
isInInjectionContext as ɵisInInjectionContext,
assertInInjectionContext,
runInInjectionContext,
} from './contextual';
export {
ɵɵdefineInjectable,
defineInjectable,
Expand Down
0