Skip to content

Commit 94f62c4

Browse files
committed
refactor: Replace queue manager with inflight manager for request handling; add timeNow utility for consistent timestamping
1 parent 308e285 commit 94f62c4

File tree

12 files changed

+242
-277
lines changed

12 files changed

+242
-277
lines changed

src/cache-manager.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
} from './types/request-handler';
99
import type { CacheEntry, MutationSettings } from './types/cache-manager';
1010
import { GET, STRING, UNDEFINED } from './constants';
11-
import { isObject, shallowSerialize, sortObject } from './utils';
11+
import { isObject, shallowSerialize, sortObject, timeNow } from './utils';
1212
import { revalidate } from './revalidator-manager';
1313
import { notifySubscribers } from './pubsub-manager';
1414
import type { DefaultPayload, DefaultParams, DefaultUrlParams } from './types';
@@ -158,7 +158,7 @@ function isCacheExpired(timestamp: number, maxStaleTime?: number): boolean {
158158
}
159159

160160
// Check if the current time exceeds the timestamp by more than maxStaleTime seconds
161-
return Date.now() - timestamp > maxStaleTime * 1000;
161+
return timeNow() - timestamp > maxStaleTime * 1000;
162162
}
163163

164164
/**
@@ -194,7 +194,7 @@ export function getCache<T>(
194194
export function setCache<T = unknown>(key: string, response: T): void {
195195
const cacheEntry: CacheEntry<T> = {
196196
data: response,
197-
timestamp: Date.now(),
197+
timestamp: timeNow(),
198198
};
199199

200200
_cache.set(key, cacheEntry);

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export { createApiFetcher } from './api-handler';
2424

2525
export { subscribe } from './pubsub-manager';
2626

27-
export { abortRequest, getInFlightPromise } from './queue-manager';
27+
export { abortRequest, getInFlightPromise } from './inflight-manager';
2828

2929
export {
3030
generateCacheKey,

src/inflight-manager.ts

Lines changed: 164 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,190 @@
11
/**
22
* @module inflight-manager
33
*
4-
* Manages the "in-flight" state of asynchronous operations identified by a key.
4+
* Manages in-flight asynchronous requests using unique keys to enable deduplication and cancellation.
5+
*
6+
* Provides utilities for:
7+
* - Deduplication of requests within a configurable time window (`dedupeTime`)
8+
* - Timeout management and automatic request abortion
9+
* - AbortController lifecycle and cancellation logic
10+
* - Concurrency control and request state tracking
11+
* - In-flight promise deduplication to prevent duplicate network calls
512
*
6-
* Provides utilities to mark a key as in-flight, unmark it, check its state,
7-
* subscribe to in-flight state changes, and execute functions with automatic
8-
* in-flight state management to prevent duplicate requests.
913
* @remarks
14+
* - Requests with the same key within the deduplication interval share the same AbortController and in-flight promise.
15+
* - Supports cancellation of previous requests when a new one with the same key is issued, if `isCancellable` is enabled.
16+
* - Timeout logic ensures requests are aborted after a specified duration, if enabled.
17+
* - Internal queue state is managed via a Map, keyed by request identifier.
1018
* - Polled requests are also marked as "in-flight" to prevent duplicate requests.
11-
* - We use a Set and the isInFlight() check to track in-flight requests, instead of updating the cache directly with setCache(), to avoid affecting the cache's persistent data.
1219
*/
1320

14-
import { notifySubscribers } from './pubsub-manager';
21+
import { ABORT_ERROR, TIMEOUT_ERROR } from './constants';
22+
import type { InFlightItem } from './types/inflight-manager';
23+
import { timeNow } from './utils';
24+
25+
const inFlight: Map<string, InFlightItem> = new Map();
26+
27+
/**
28+
* Adds a request to the queue if it's not already being processed within the dedupeTime interval.
29+
*
30+
* @param {string | null} key - Unique key for the request (e.g. cache key).
31+
* @param {string} url - The request URL (for error messages/timeouts).
32+
* @param {number} timeout - Timeout in milliseconds for the request.
33+
* @param {number} dedupeTime - Deduplication time in milliseconds.
34+
* @param {boolean} isCancellable - If true, then the previous request with same configuration should be aborted.
35+
* @param {boolean} isTimeoutEnabled - Whether timeout is enabled.
36+
* @returns {Promise<AbortController>} - A promise that resolves to an AbortController.
37+
*/
38+
export async function markInFlight(
39+
key: string | null,
40+
url: string,
41+
timeout: number | undefined,
42+
dedupeTime: number = 0,
43+
isCancellable: boolean = false,
44+
isTimeoutEnabled: boolean = true,
45+
): Promise<AbortController> {
46+
if (!key) {
47+
return new AbortController();
48+
}
49+
50+
const item = inFlight.get(key);
51+
52+
if (item) {
53+
const prevIsCancellable = item[3];
54+
const previousController = item[0];
55+
const timeoutId = item[1];
56+
57+
// If the request is already in the queue and within the dedupeTime, reuse the existing controller
58+
if (!prevIsCancellable && dedupeTime && timeNow() - item[2] < dedupeTime) {
59+
return previousController;
60+
}
61+
62+
// If the request is too old, remove it and proceed to add a new one
63+
// Abort previous request, if applicable, and continue as usual
64+
if (prevIsCancellable) {
65+
previousController.abort(
66+
new DOMException('Aborted due to new request', ABORT_ERROR),
67+
);
68+
}
69+
70+
if (timeoutId !== null) {
71+
clearTimeout(timeoutId);
72+
}
73+
74+
inFlight.delete(key);
75+
}
76+
77+
const controller = new AbortController();
78+
79+
const timeoutId = isTimeoutEnabled
80+
? setTimeout(() => {
81+
const error = new DOMException(
82+
`${url} aborted due to timeout`,
83+
TIMEOUT_ERROR,
84+
);
85+
86+
abortRequest(key, error);
87+
}, timeout)
88+
: null;
89+
90+
inFlight.set(key, [controller, timeoutId, timeNow(), isCancellable]);
91+
92+
return controller;
93+
}
94+
95+
/**
96+
* Removes a request from the queue and clears its timeout.
97+
*
98+
* @param key - Unique key for the request.
99+
* @param {boolean} error - Error payload so to force the request to abort.
100+
*/
101+
export async function abortRequest(
102+
key: string | null,
103+
error: DOMException | null | string = null,
104+
): Promise<void> {
105+
// If the key is not in the queue, there's nothing to remove
106+
if (!key) {
107+
return;
108+
}
109+
110+
const item = inFlight.get(key);
15111

16-
const inFlight = new Set<string>();
112+
if (item) {
113+
const controller = item[0];
114+
const timeoutId = item[1];
17115

18-
export function markInFlight(key: string) {
19-
inFlight.add(key);
116+
// If the request is not yet aborted, abort it with the provided error
117+
if (error && !controller.signal.aborted) {
118+
controller.abort(error);
119+
}
20120

21-
notifySubscribers(key, { isFetching: true });
121+
if (timeoutId !== null) {
122+
clearTimeout(timeoutId);
123+
}
124+
125+
inFlight.delete(key);
126+
}
22127
}
23128

24-
export function unmarkInFlight(key: string) {
25-
inFlight.delete(key);
129+
/**
130+
* Gets the AbortController for a request key.
131+
*
132+
* @param key - Unique key for the request.
133+
* @returns {AbortController | undefined} - The AbortController or undefined.
134+
*/
135+
export async function getController(
136+
key: string,
137+
): Promise<AbortController | undefined> {
138+
const item = inFlight.get(key);
139+
140+
return item?.[0];
26141
}
27142

28-
export function isInFlight(key: string) {
29-
return inFlight.has(key);
143+
/**
144+
* Adds helpers for in-flight promise deduplication.
145+
*
146+
* @param key - Unique key for the request.
147+
* @param promise - The promise to store.
148+
*/
149+
export function setInFlightPromise(
150+
key: string,
151+
promise: Promise<unknown>,
152+
): void {
153+
const item = inFlight.get(key);
154+
if (item) {
155+
// store the promise at index 4
156+
item[4] = promise;
157+
158+
inFlight.set(key, item);
159+
}
30160
}
31161

32162
/**
33-
* Executes a function while marking a key as in-flight.
34-
* This is useful for preventing duplicate requests for the same resource.
163+
* Retrieves the in-flight promise for a request key if it exists and is within the dedupeTime interval.
35164
*
36-
* @param {string} key - The key to mark as in-flight.
37-
* @param {() => T} fn - The function to execute.
38-
* @returns {Promise<T>} - The result of the function execution.
165+
* @param key - Unique key for the request.
166+
* @param dedupeTime - Deduplication time in milliseconds.
167+
* @returns {Promise<T> | null} - The in-flight promise or null.
39168
*/
40-
export async function withInFlight<T>(key: string, fn: () => T): Promise<T> {
169+
export function getInFlightPromise<T = unknown>(
170+
key: string | null,
171+
dedupeTime: number,
172+
): Promise<T> | null {
41173
if (!key) {
42-
return fn();
174+
return null;
43175
}
44176

45-
markInFlight(key);
177+
const item = inFlight.get(key);
46178

47-
try {
48-
return fn();
49-
} finally {
50-
unmarkInFlight(key);
179+
if (
180+
item &&
181+
item[4] &&
182+
timeNow() - item[2] < dedupeTime &&
183+
// If one request is cancelled, ALL deduped requests get cancelled
184+
!item[0].signal.aborted
185+
) {
186+
return item[4] as Promise<T>;
51187
}
188+
189+
return null;
52190
}

0 commit comments

Comments
 (0)