Skip to content

Commit c2f9c5b

Browse files
committed
fix: Deduplication logic should not block cancellable requests
1 parent bbf68e0 commit c2f9c5b

File tree

5 files changed

+83
-56
lines changed

5 files changed

+83
-56
lines changed

src/inflight-manager.ts

Lines changed: 57 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,17 @@
1919
*/
2020

2121
import { ABORT_ERROR, TIMEOUT_ERROR } from './constants';
22-
import type { InFlightItem } from './types/inflight-manager';
22+
import { addTimeout, removeTimeout } from './timeout-wheel';
2323
import { timeNow } from './utils';
2424

25+
export type InFlightItem = [
26+
AbortController, // AbortController for the request
27+
boolean, // Whether timeout is enabled for the request
28+
number, // Timestamp when the request was marked in-flight
29+
boolean, // isCancellable - whether the request can be cancelled
30+
Promise<unknown> | null, // Optional in-flight promise for deduplication
31+
];
32+
2533
const inFlight: Map<string, InFlightItem> = new Map();
2634

2735
/**
@@ -39,56 +47,65 @@ export async function markInFlight(
3947
key: string | null,
4048
url: string,
4149
timeout: number | undefined,
42-
dedupeTime: number = 0,
43-
isCancellable: boolean = false,
44-
isTimeoutEnabled: boolean = true,
50+
dedupeTime: number,
51+
isCancellable: boolean,
52+
isTimeoutEnabled: boolean,
4553
): Promise<AbortController> {
4654
if (!key) {
4755
return new AbortController();
4856
}
4957

5058
const item = inFlight.get(key);
59+
let prevPromise: Promise<unknown> | null = null;
5160

61+
// Previous request is in-flight, check if we can reuse it
5262
if (item) {
53-
const previousController = item[0];
63+
const prevController = item[0];
5464
const prevIsCancellable = item[3];
5565

5666
// If the request is already in the queue and within the dedupeTime, reuse the existing controller
57-
if (!prevIsCancellable && dedupeTime && timeNow() - item[2] < dedupeTime) {
58-
return previousController;
67+
if (
68+
!prevIsCancellable &&
69+
timeNow() - item[2] < dedupeTime &&
70+
!prevController.signal.aborted
71+
) {
72+
return prevController;
5973
}
6074

6175
// If the request is too old, remove it and proceed to add a new one
6276
// Abort previous request, if applicable, and continue as usual
6377
if (prevIsCancellable) {
64-
previousController.abort(
78+
prevController.abort(
6579
new DOMException('Aborted due to new request', ABORT_ERROR),
6680
);
6781
}
6882

69-
const timeoutId = item[1];
70-
71-
if (timeoutId !== null) {
72-
clearTimeout(timeoutId);
73-
}
74-
75-
inFlight.delete(key);
83+
removeTimeout(key);
84+
prevPromise = item[4];
7685
}
7786

7887
const controller = new AbortController();
7988

80-
const timeoutId = isTimeoutEnabled
81-
? setTimeout(() => {
82-
const error = new DOMException(
83-
`${url} aborted due to timeout`,
84-
TIMEOUT_ERROR,
89+
inFlight.set(key, [
90+
controller,
91+
isTimeoutEnabled,
92+
timeNow(),
93+
isCancellable,
94+
prevPromise,
95+
]);
96+
97+
if (isTimeoutEnabled) {
98+
addTimeout(
99+
key,
100+
() => {
101+
abortRequest(
102+
key,
103+
new DOMException(url + ' aborted due to timeout', TIMEOUT_ERROR),
85104
);
86-
87-
abortRequest(key, error);
88-
}, timeout)
89-
: null;
90-
91-
inFlight.set(key, [controller, timeoutId, timeNow(), isCancellable]);
105+
},
106+
timeout as number,
107+
);
108+
}
92109

93110
return controller;
94111
}
@@ -97,7 +114,8 @@ export async function markInFlight(
97114
* Removes a request from the queue and clears its timeout.
98115
*
99116
* @param key - Unique key for the request.
100-
* @param {boolean} error - Error payload so to force the request to abort.
117+
* @param {boolean} error - Optional error to abort the request with. If null, the request is simply removed but no abort sent.
118+
* @returns {Promise<void>} - A promise that resolves when the request is aborted and removed.
101119
*/
102120
export async function abortRequest(
103121
key: string | null,
@@ -112,17 +130,13 @@ export async function abortRequest(
112130

113131
if (item) {
114132
const controller = item[0];
115-
const timeoutId = item[1];
116133

117134
// If the request is not yet aborted, abort it with the provided error
118-
if (error && !controller.signal.aborted) {
135+
if (error) {
119136
controller.abort(error);
120137
}
121138

122-
if (timeoutId !== null) {
123-
clearTimeout(timeoutId);
124-
}
125-
139+
removeTimeout(key);
126140
inFlight.delete(key);
127141
}
128142
}
@@ -175,16 +189,20 @@ export function getInFlightPromise<T = unknown>(
175189
return null;
176190
}
177191

178-
const item = inFlight.get(key);
192+
const prevReq = inFlight.get(key);
179193

180194
if (
181-
item &&
182-
item[4] &&
183-
timeNow() - item[2] < dedupeTime &&
195+
prevReq &&
196+
// If the request is in-flight and has a promise
197+
prevReq[4] &&
198+
// If the request is cancellable, we will not reuse it
199+
!prevReq[3] &&
200+
// If the request is within the dedupeTime
201+
timeNow() - prevReq[2] < dedupeTime &&
184202
// If one request is cancelled, ALL deduped requests get cancelled
185-
!item[0].signal.aborted
203+
!prevReq[0].signal.aborted
186204
) {
187-
return item[4] as Promise<T>;
205+
return prevReq[4] as Promise<T>;
188206
}
189207

190208
return null;

src/react/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,13 @@ import type {
2525
} from '..';
2626
import type { UseFetcherResult } from '../types/react-hooks';
2727

28-
import { decrementRef, incrementRef, INFINITE_CACHE_TIME } from './cache-ref';
28+
import {
29+
decrementRef,
30+
DEFAULT_DEDUPE_TIME_MS,
31+
incrementRef,
32+
INFINITE_CACHE_TIME,
33+
} from './cache-ref';
2934

30-
const DEFAULT_DEDUPE_TIME_MS = 2000;
3135
const DEFAULT_RESULT = {
3236
data: null,
3337
error: null,

src/request-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,8 @@ export function createRequestHandler(
187187
_cacheKey,
188188
url,
189189
timeout,
190-
dedupeTime,
191-
cancellable,
190+
dedupeTime || 0,
191+
!!cancellable,
192192
// Reset timeouts by default or when retries are ON
193193
!!(timeout && (!_retries || resetTimeout)),
194194
);

src/types/inflight-manager.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

test/inflight-manager.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import {
2-
markInFlight,
2+
markInFlight as _markInFlight,
33
abortRequest,
44
getController,
55
} from '../src/inflight-manager';
66

77
const createKey = (url: string) => url;
8+
const markInFlight = async (
9+
key: string,
10+
url: string,
11+
timeout: number | undefined,
12+
dedupeTime: number = 0,
13+
isCancellable: boolean = false,
14+
isTimeoutEnabled: boolean = true,
15+
): Promise<AbortController> => {
16+
return _markInFlight(
17+
key,
18+
url,
19+
timeout,
20+
dedupeTime,
21+
isCancellable,
22+
isTimeoutEnabled,
23+
);
24+
};
825

926
describe('InFlight Request Manager', () => {
1027
beforeAll(() => {

0 commit comments

Comments
 (0)