diff --git a/src/index.test.ts b/src/index.test.ts index 3d8a02a..baf7655 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -597,6 +597,25 @@ test('Should publish error when fetch fails', (done) => { }); }); +test('Should abort previous request', async () => { + fetchMock.mockResponse(JSON.stringify(data)); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + + const config: IConfig = { + url: 'http://localhost/test', + clientKey: '12', + appName: 'web', + }; + const client = new UnleashClient(config); + await client.start(); + client.updateContext({ userId: '123' }); // abort 1 + client.updateContext({ userId: '456' }); // abort 2 + await client.updateContext({ userId: '789' }); + + expect(abortSpy).toBeCalledTimes(2); + abortSpy.mockRestore(); +}); + test.each([400, 401, 403, 404, 429, 500, 502, 503])( 'Should publish error when fetch receives a %d error', async (errorCode) => { diff --git a/src/index.ts b/src/index.ts index 3139326..69caf58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ interface IConfig extends IStaticContext { storageProvider?: IStorageProvider; context?: IMutableContext; fetch?: any; + createAbortController?: () => AbortController; bootstrap?: IToggle[]; bootstrapOverride?: boolean; headerName?: string; @@ -94,6 +95,18 @@ export const resolveFetch = () => { return undefined; }; +const resolveAbortController = () => { + try { + if (typeof window !== 'undefined' && 'AbortController' in window) { + return () => new window.AbortController(); + } else if ('fetch' in globalThis) { + return () => new globalThis.AbortController(); + } + } catch (e) { + console.error('Unleash failed to resolve "AbortController" factory', e); + } +}; + export class UnleashClient extends TinyEmitter { private toggles: IToggle[] = []; private impressionDataAll: boolean; @@ -107,6 +120,8 @@ export class UnleashClient extends TinyEmitter { private metrics: Metrics; private ready: Promise; private fetch: any; + private createAbortController?: () => AbortController; + private abortController?: AbortController | null; private bootstrap?: IToggle[]; private bootstrapOverride: boolean; private headerName: string; @@ -128,6 +143,7 @@ export class UnleashClient extends TinyEmitter { environment = 'default', context, fetch = resolveFetch(), + createAbortController = resolveAbortController(), bootstrap, bootstrapOverride = true, headerName = 'Authorization', @@ -176,8 +192,14 @@ export class UnleashClient extends TinyEmitter { 'Unleash: You must either provide your own "fetch" implementation or run in an environment where "fetch" is available.' ); } + if (!createAbortController) { + console.error( + 'Unleash: You must either provide your own "AbortController" implementation or run in an environment where "AbortController" is available.' + ); + } this.fetch = fetch; + this.createAbortController = createAbortController; this.bootstrap = bootstrap && bootstrap.length > 0 ? bootstrap : undefined; this.bootstrapOverride = bootstrapOverride; @@ -366,6 +388,15 @@ export class UnleashClient extends TinyEmitter { private async fetchToggles() { if (this.fetch) { + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = + this.createAbortController && this.createAbortController(); + const signal = this.abortController + ? this.abortController.signal + : undefined; + try { const isPOST = this.usePOSTrequests; @@ -382,6 +413,7 @@ export class UnleashClient extends TinyEmitter { cache: 'no-cache', headers: this.getHeaders(), body, + signal, }); if (response.ok && response.status !== 304) { this.etag = response.headers.get('ETag') || ''; @@ -404,6 +436,8 @@ export class UnleashClient extends TinyEmitter { } catch (e) { console.error('Unleash: unable to fetch feature toggles', e); this.emit(EVENTS.ERROR, e); + } finally { + this.abortController = null; } } }