Skip to content

Commit 8fbfae7

Browse files
committed
enhance: Change NetworkManager bookkeeping data structure for inflight fetches
1 parent 25b153a commit 8fbfae7

File tree

5 files changed

+93
-59
lines changed

5 files changed

+93
-59
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
@@ -26,6 +26,7 @@ export {
2626
default as NetworkManager,
2727
ResetError,
2828
} from './manager/NetworkManager.js';
29+
export type { FetchingMeta } from './manager/NetworkManager.js';
2930
export * from './state/GCPolicy.js';
3031
export {
3132
default as createReducer,

packages/core/src/manager/NetworkManager.ts

Lines changed: 74 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 FetchingMeta {
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, FetchingMeta> = 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 FetchingMeta).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 FetchingMeta;
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,54 @@ 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: FetchingMeta;
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 FetchingMeta).fetchedAt <=
273+
lastReset
274+
) {
275+
const promiseHandlers: {
276+
resolve?: (value: any) => void;
277+
reject?: (value: any) => void;
278+
} = {};
279+
fetchMeta = {
280+
promise: new Promise((resolve, reject) => {
281+
promiseHandlers.resolve = resolve;
282+
promiseHandlers.reject = reject;
283+
}),
284+
resolve(value) {
285+
// if this is called before the promise callback runs it might not be set yet
286+
if (promiseHandlers.resolve) return promiseHandlers.resolve(value);
287+
// this should be safe (no infinite recursion), since our new Promise is not conditional
288+
setTimeout(fetchMeta.resolve, 0);
289+
},
290+
reject(value) {
291+
// if this is called before the promise callback runs it might not be set yet
292+
if (promiseHandlers.reject) return promiseHandlers.reject(value);
293+
// this should be safe (no infinite recursion), since our new Promise is not conditional
294+
setTimeout(fetchMeta.reject, 0);
295+
},
296+
fetchedAt,
297+
} as FetchingMeta;
298+
this.fetching.set(key, fetchMeta);
263299

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-
);
300+
this.idleCallback(
301+
() => {
302+
// since our real promise is resolved via the wrapReducer(),
303+
// we should just stop all errors here.
304+
// TODO: decouple this from useFetcher() (that's what's dispatching the error the resolves in here)
305+
fetch().catch(() => null);
306+
},
307+
{ timeout: 500 },
308+
);
309+
}
279310

280-
return this.fetched[key];
311+
return fetchMeta.promise;
281312
}
282313

283314
/** 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
};

website/src/components/Playground/editor-types/@data-client/core.d.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,12 @@ declare class ResetError extends Error {
889889
name: string;
890890
constructor();
891891
}
892+
interface FetchingMeta {
893+
promise: Promise<any>;
894+
resolve: (value?: any) => void;
895+
reject: (value?: any) => void;
896+
fetchedAt: number;
897+
}
892898
/** Handles all async network dispatches
893899
*
894900
* Dedupes concurrent requests by keeping track of all fetches in flight
@@ -899,18 +905,7 @@ declare class ResetError extends Error {
899905
* @see https://dataclient.io/docs/api/NetworkManager
900906
*/
901907
declare class NetworkManager implements Manager {
902-
protected fetched: {
903-
[k: string]: Promise<any>;
904-
};
905-
protected resolvers: {
906-
[k: string]: (value?: any) => void;
907-
};
908-
protected rejectors: {
909-
[k: string]: (value?: any) => void;
910-
};
911-
protected fetchedAt: {
912-
[k: string]: number;
913-
};
908+
protected fetching: Map<string, FetchingMeta>;
914909
readonly dataExpiryLength: number;
915910
readonly errorExpiryLength: number;
916911
protected controller: Controller;
@@ -1371,4 +1366,4 @@ interface Props {
13711366
shouldLogout?: (error: UnknownError) => boolean;
13721367
}
13731368

1374-
export { type AbstractInstanceType, type ActionMeta, type ActionTypes, type ConnectionListener, Controller, type CreateCountRef, type DataClientDispatch, DefaultConnectionListener, type Denormalize, type DenormalizeNullable, type DevToolsConfig, DevToolsManager, type Dispatch, type EndpointExtraOptions, type EndpointInterface, type EndpointUpdateFunction, type EntityInterface, type ErrorTypes, type ExpireAllAction, ExpiryStatus, type FetchAction, type FetchFunction, type FetchMeta, type GCAction, type GCInterface, type GCOptions, GCPolicy, type GenericDispatch, type INormalizeDelegate, type IQueryDelegate, ImmortalGCPolicy, type InvalidateAction, type InvalidateAllAction, LogoutManager, type Manager, type Mergeable, type Middleware, type MiddlewareAPI, type NI, type NetworkError, NetworkManager, type Normalize, type NormalizeNullable, type OptimisticAction, type PK, PollingSubscription, type Queryable, type ResetAction, ResetError, type ResolveType, type ResultEntry, type Schema, type SchemaArgs, type SchemaClass, type SetAction, type SetResponseAction, type SetResponseActionBase, type SetResponseActionError, type SetResponseActionSuccess, type State, type SubscribeAction, SubscriptionManager, type UnknownError, type UnsubscribeAction, type UpdateFunction, internal_d as __INTERNAL__, actionTypes_d as actionTypes, index_d as actions, applyManager, createReducer, initManager, initialState };
1369+
export { type AbstractInstanceType, type ActionMeta, type ActionTypes, type ConnectionListener, Controller, type CreateCountRef, type DataClientDispatch, DefaultConnectionListener, type Denormalize, type DenormalizeNullable, type DevToolsConfig, DevToolsManager, type Dispatch, type EndpointExtraOptions, type EndpointInterface, type EndpointUpdateFunction, type EntityInterface, type ErrorTypes, type ExpireAllAction, ExpiryStatus, type FetchAction, type FetchFunction, type FetchMeta, type FetchingMeta, type GCAction, type GCInterface, type GCOptions, GCPolicy, type GenericDispatch, type INormalizeDelegate, type IQueryDelegate, ImmortalGCPolicy, type InvalidateAction, type InvalidateAllAction, LogoutManager, type Manager, type Mergeable, type Middleware, type MiddlewareAPI, type NI, type NetworkError, NetworkManager, type Normalize, type NormalizeNullable, type OptimisticAction, type PK, PollingSubscription, type Queryable, type ResetAction, ResetError, type ResolveType, type ResultEntry, type Schema, type SchemaArgs, type SchemaClass, type SetAction, type SetResponseAction, type SetResponseActionBase, type SetResponseActionError, type SetResponseActionSuccess, type State, type SubscribeAction, SubscriptionManager, type UnknownError, type UnsubscribeAction, type UpdateFunction, internal_d as __INTERNAL__, actionTypes_d as actionTypes, index_d as actions, applyManager, createReducer, initManager, initialState };

0 commit comments

Comments
 (0)