Skip to content

Commit 6d946d5

Browse files
committed
enhance: Change NetworkManager bookkeeping data structure for inflight fetches
1 parent 9bf7e7a commit 6d946d5

File tree

4 files changed

+84
-46
lines changed

4 files changed

+84
-46
lines changed

.changeset/sour-horses-give.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@data-client/core': minor
3+
'@data-client/test': minor
4+
---
5+
6+
Change NetworkManager bookkeeping data structure for inflight fetches
7+
8+
BREAKING CHANGE: NetworkManager.fetched, NetworkManager.rejectors, NetworkManager.resolvers, NetworkManager.fetchedAt
9+
-> NetworkManager.fetching

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export { ExpiryStatus } from '@data-client/normalizr';
2222
export {
2323
default as NetworkManager,
2424
ResetError,
25+
FetchMeta,
2526
} from './manager/NetworkManager.js';
2627
export * from './state/GCPolicy.js';
2728
export {

packages/core/src/manager/NetworkManager.ts

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type {
55
FetchAction,
66
Manager,
77
ActionTypes,
8-
MiddlewareAPI,
98
Middleware,
109
SetResponseAction,
1110
} from '../types.js';
@@ -18,6 +17,13 @@ export class ResetError extends Error {
1817
}
1918
}
2019

20+
export interface FetchMeta {
21+
promise: Promise<any>;
22+
resolve: (value?: any) => void;
23+
reject: (value?: any) => void;
24+
fetchedAt: number;
25+
}
26+
2127
/** Handles all async network dispatches
2228
*
2329
* Dedupes concurrent requests by keeping track of all fetches in flight
@@ -28,10 +34,7 @@ export class ResetError extends Error {
2834
* @see https://dataclient.io/docs/api/NetworkManager
2935
*/
3036
export default class NetworkManager implements Manager {
31-
protected fetched: { [k: string]: Promise<any> } = Object.create(null);
32-
protected resolvers: { [k: string]: (value?: any) => void } = {};
33-
protected rejectors: { [k: string]: (value?: any) => void } = {};
34-
protected fetchedAt: { [k: string]: number } = {};
37+
protected fetching: Map<string, FetchMeta> = new Map();
3538
declare readonly dataExpiryLength: number;
3639
declare readonly errorExpiryLength: number;
3740
protected controller: Controller = new Controller();
@@ -61,7 +64,7 @@ export default class NetworkManager implements Manager {
6164
case SET_RESPONSE:
6265
// only set after new state is computed
6366
return next(action).then(() => {
64-
if (action.key in this.fetched) {
67+
if (this.fetching.has(action.key)) {
6568
// Note: meta *must* be set by reducer so this should be safe
6669
const error = controller.getState().meta[action.key]?.error;
6770
// processing errors result in state meta having error, so we should reject the promise
@@ -80,14 +83,18 @@ export default class NetworkManager implements Manager {
8083
}
8184
});
8285
case RESET: {
83-
const rejectors = { ...this.rejectors };
86+
// take snapshot of rejectors at this point in time
87+
// we must use Array.from since iteration does not freeze state at this point in time
88+
const rejectors = Array.from(
89+
this.fetching.values().map(({ reject }) => reject),
90+
);
8491

8592
this.clearAll();
8693
return next(action).then(() => {
8794
// there could be external listeners to the promise
8895
// this must happen after commit so our own rejector knows not to dispatch an error based on this
89-
for (const k in rejectors) {
90-
rejectors[k](new ResetError());
96+
for (const rejector of rejectors) {
97+
rejector(new ResetError());
9198
}
9299
});
93100
}
@@ -112,28 +119,29 @@ export default class NetworkManager implements Manager {
112119
/** Used by DevtoolsManager to determine whether to log an action */
113120
skipLogging(action: ActionTypes) {
114121
/* istanbul ignore next */
115-
return action.type === FETCH && action.key in this.fetched;
122+
return action.type === FETCH && this.fetching.has(action.key);
116123
}
117124

118125
allSettled() {
119-
const fetches = Object.values(this.fetched);
120-
if (fetches.length) return Promise.allSettled(fetches);
126+
if (this.fetching.size)
127+
return Promise.allSettled(
128+
this.fetching.values().map(({ promise }) => promise),
129+
);
121130
}
122131

123132
/** Clear all promise state */
124133
protected clearAll() {
125-
for (const k in this.rejectors) {
134+
for (const k of this.fetching.keys()) {
126135
this.clear(k);
127136
}
128137
}
129138

130139
/** Clear promise state for a given key */
131140
protected clear(key: string) {
132-
this.fetched[key].catch(() => {});
133-
delete this.resolvers[key];
134-
delete this.rejectors[key];
135-
delete this.fetched[key];
136-
delete this.fetchedAt[key];
141+
if (this.fetching.has(key)) {
142+
(this.fetching.get(key) as FetchMeta).promise.catch(() => {});
143+
this.fetching.delete(key);
144+
}
137145
}
138146

139147
protected getLastReset() {
@@ -226,14 +234,14 @@ export default class NetworkManager implements Manager {
226234
*/
227235
protected handleSet(action: SetResponseAction) {
228236
// this can still turn out to be untrue since this is async
229-
if (action.key in this.fetched) {
230-
let promiseHandler: (value?: any) => void;
237+
if (this.fetching.has(action.key)) {
238+
const fetchMeta = this.fetching.get(action.key) as FetchMeta;
231239
if (action.error) {
232-
promiseHandler = this.rejectors[action.key];
240+
fetchMeta.reject(action.response);
233241
} else {
234-
promiseHandler = this.resolvers[action.key];
242+
fetchMeta.resolve(action.response);
235243
}
236-
promiseHandler(action.response);
244+
237245
// since we're resolved we no longer need to keep track of this promise
238246
this.clear(action.key);
239247
}
@@ -253,31 +261,53 @@ export default class NetworkManager implements Manager {
253261
key: string,
254262
fetch: () => Promise<any>,
255263
fetchedAt: number,
256-
) {
264+
): Promise<any> {
257265
const lastReset = this.getLastReset();
266+
let fetchMeta: FetchMeta;
267+
258268
// we're already fetching so reuse the promise
259269
// fetches after reset do not count
260-
if (key in this.fetched && this.fetchedAt[key] > lastReset) {
261-
return this.fetched[key];
262-
}
270+
if (
271+
!this.fetching.has(key) ||
272+
(fetchMeta = this.fetching.get(key) as FetchMeta).fetchedAt <= lastReset
273+
) {
274+
const promiseHandlers: {
275+
resolve?: (value: any) => void;
276+
reject?: (value: any) => void;
277+
} = {};
278+
fetchMeta = {
279+
promise: new Promise((resolve, reject) => {
280+
promiseHandlers.resolve = resolve;
281+
promiseHandlers.reject = reject;
282+
}),
283+
resolve(value) {
284+
// if this is called before the promise callback runs it might not be set yet
285+
if (promiseHandlers.resolve) return promiseHandlers.resolve(value);
286+
// this should be safe (no infinite recursion), since our new Promise is not conditional
287+
setTimeout(fetchMeta.resolve, 0);
288+
},
289+
reject(value) {
290+
// if this is called before the promise callback runs it might not be set yet
291+
if (promiseHandlers.reject) return promiseHandlers.reject(value);
292+
// this should be safe (no infinite recursion), since our new Promise is not conditional
293+
setTimeout(fetchMeta.reject, 0);
294+
},
295+
fetchedAt,
296+
} as FetchMeta;
297+
this.fetching.set(key, fetchMeta);
263298

264-
this.fetched[key] = new Promise((resolve, reject) => {
265-
this.resolvers[key] = resolve;
266-
this.rejectors[key] = reject;
267-
});
268-
this.fetchedAt[key] = fetchedAt;
269-
270-
this.idleCallback(
271-
() => {
272-
// since our real promise is resolved via the wrapReducer(),
273-
// we should just stop all errors here.
274-
// TODO: decouple this from useFetcher() (that's what's dispatching the error the resolves in here)
275-
fetch().catch(() => null);
276-
},
277-
{ timeout: 500 },
278-
);
299+
this.idleCallback(
300+
() => {
301+
// since our real promise is resolved via the wrapReducer(),
302+
// we should just stop all errors here.
303+
// TODO: decouple this from useFetcher() (that's what's dispatching the error the resolves in here)
304+
fetch().catch(() => null);
305+
},
306+
{ timeout: 500 },
307+
);
308+
}
279309

280-
return this.fetched[key];
310+
return fetchMeta.promise;
281311
}
282312

283313
/** Calls the callback when client is not 'busy' with high priority interaction tasks

packages/test/src/makeRenderDataClient/index.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,7 @@ export default function makeRenderDataHook(
8383
// TODO: move to return value
8484
renderDataClient.cleanup = () => {
8585
nm.cleanupDate = Infinity;
86-
Object.values(nm['rejectors'] as Record<string, any>).forEach(rej => {
87-
rej();
88-
});
86+
nm['fetching'].forEach(({ reject }) => reject());
8987
nm['clearAll']();
9088
managers.forEach(manager => manager.cleanup());
9189
};

0 commit comments

Comments
 (0)