feat: implement global loading state with interceptor and component

This commit is contained in:
2026-07-29 11:03:35 -03:00
parent 1fb9789abf
commit 125d9137fb
11 changed files with 354 additions and 3 deletions

View File

@@ -0,0 +1,79 @@
import '@angular/compiler';
import { HttpClient, HttpContext, provideHttpClient, withInterceptors } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SKIP_GLOBAL_LOADING,
globalLoadingInterceptor
} from './global-loading.interceptor';
import { GlobalLoadingService } from './global-loading.service';
describe('globalLoadingInterceptor', () => {
let client: HttpClient;
let httpTesting: HttpTestingController;
let loadingService: GlobalLoadingService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([globalLoadingInterceptor])),
provideHttpClientTesting()
]
});
client = TestBed.inject(HttpClient);
httpTesting = TestBed.inject(HttpTestingController);
loadingService = TestBed.inject(GlobalLoadingService);
});
afterEach(() => {
httpTesting.verify();
TestBed.resetTestingModule();
vi.useRealTimers();
});
it('tracks concurrent requests until the last one completes', () => {
client.get('/api/first').subscribe();
client.get('/api/second').subscribe();
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/first').flush({});
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/second').flush({});
expect(loadingService.isLoading()).toBe(false);
});
it('stops loading when a request fails', () => {
client.get('/api/failing').subscribe({ error: () => undefined });
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(true);
httpTesting.expectOne('/api/failing').flush(
{ message: 'Error' },
{ status: 500, statusText: 'Server Error' }
);
expect(loadingService.isLoading()).toBe(false);
});
it('allows background requests to opt out', () => {
const context = new HttpContext().set(SKIP_GLOBAL_LOADING, true);
client.get('/api/background', { context }).subscribe();
vi.advanceTimersByTime(150);
expect(loadingService.isLoading()).toBe(false);
httpTesting.expectOne('/api/background').flush({});
});
});

View File

@@ -0,0 +1,27 @@
import {
HttpContextToken,
HttpEvent,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, finalize } from 'rxjs';
import { GlobalLoadingService } from './global-loading.service';
export const SKIP_GLOBAL_LOADING = new HttpContextToken<boolean>(() => false);
export const globalLoadingInterceptor: HttpInterceptorFn = (
request: HttpRequest<unknown>,
next: HttpHandlerFn
): Observable<HttpEvent<unknown>> => {
if (request.context.get(SKIP_GLOBAL_LOADING)) {
return next(request);
}
const loadingService = inject(GlobalLoadingService);
loadingService.start();
return next(request).pipe(finalize(() => loadingService.stop()));
};

View File

@@ -0,0 +1,65 @@
import '@angular/compiler';
import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GlobalLoadingService } from './global-loading.service';
describe('GlobalLoadingService', () => {
let service: GlobalLoadingService;
beforeEach(() => {
vi.useFakeTimers();
TestBed.configureTestingModule({});
service = TestBed.inject(GlobalLoadingService);
});
afterEach(() => {
TestBed.resetTestingModule();
vi.useRealTimers();
});
it('delays the loading state to avoid flashes on fast operations', () => {
service.start();
expect(service.isLoading()).toBe(false);
service.stop();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(false);
});
it('shows the loading state for an operation that exceeds the delay', () => {
service.start();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
it('stays visible until all concurrent operations finish', () => {
service.start();
service.start();
vi.advanceTimersByTime(150);
service.stop();
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
it('ignores extra stop calls without corrupting the counter', () => {
service.stop();
service.start();
vi.advanceTimersByTime(150);
expect(service.isLoading()).toBe(true);
service.stop();
expect(service.isLoading()).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
import { isPlatformBrowser } from '@angular/common';
import { Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
const SHOW_DELAY_MS = 150;
@Injectable({ providedIn: 'root' })
export class GlobalLoadingService {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly loadingState = signal(false);
private pendingOperations = 0;
private showTimer: ReturnType<typeof setTimeout> | undefined;
readonly isLoading = this.loadingState.asReadonly();
start(): void {
if (!this.isBrowser) {
return;
}
this.pendingOperations += 1;
if (this.pendingOperations > 1 || this.loadingState()) {
return;
}
this.showTimer = setTimeout(() => {
this.showTimer = undefined;
if (this.pendingOperations > 0) {
this.loadingState.set(true);
}
}, SHOW_DELAY_MS);
}
stop(): void {
if (!this.isBrowser || this.pendingOperations === 0) {
return;
}
this.pendingOperations -= 1;
if (this.pendingOperations > 0) {
return;
}
this.cancelShowTimer();
this.loadingState.set(false);
}
private cancelShowTimer(): void {
if (this.showTimer === undefined) {
return;
}
clearTimeout(this.showTimer);
this.showTimer = undefined;
}
}