@khanacademy/wonder-blocks-data 5.0.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/es/index.js +767 -371
- package/dist/index.js +1194 -564
- package/legacy-docs.md +3 -0
- package/package.json +2 -2
- package/src/__docs__/_overview_.stories.mdx +18 -0
- package/src/__docs__/_overview_graphql.stories.mdx +35 -0
- package/src/__docs__/_overview_ssr_.stories.mdx +185 -0
- package/src/__docs__/_overview_testing_.stories.mdx +123 -0
- package/src/__docs__/exports.clear-shared-cache.stories.mdx +20 -0
- package/src/__docs__/exports.data-error.stories.mdx +23 -0
- package/src/__docs__/exports.data-errors.stories.mdx +23 -0
- package/src/{components/data.md → __docs__/exports.data.stories.mdx} +15 -18
- package/src/__docs__/exports.fulfill-all-data-requests.stories.mdx +24 -0
- package/src/__docs__/exports.gql-error.stories.mdx +23 -0
- package/src/__docs__/exports.gql-errors.stories.mdx +20 -0
- package/src/__docs__/exports.gql-router.stories.mdx +29 -0
- package/src/__docs__/exports.has-unfulfilled-requests.stories.mdx +20 -0
- package/src/{components/intercept-requests.md → __docs__/exports.intercept-requests.stories.mdx} +16 -1
- package/src/__docs__/exports.intialize-cache.stories.mdx +29 -0
- package/src/__docs__/exports.remove-all-from-cache.stories.mdx +24 -0
- package/src/__docs__/exports.remove-from-cache.stories.mdx +25 -0
- package/src/__docs__/exports.request-fulfillment.stories.mdx +36 -0
- package/src/__docs__/exports.scoped-in-memory-cache.stories.mdx +92 -0
- package/src/__docs__/exports.serializable-in-memory-cache.stories.mdx +112 -0
- package/src/__docs__/exports.status.stories.mdx +31 -0
- package/src/{components/track-data.md → __docs__/exports.track-data.stories.mdx} +15 -0
- package/src/__docs__/exports.use-cached-effect.stories.mdx +41 -0
- package/src/__docs__/exports.use-gql.stories.mdx +73 -0
- package/src/__docs__/exports.use-hydratable-effect.stories.mdx +43 -0
- package/src/__docs__/exports.use-server-effect.stories.mdx +50 -0
- package/src/__docs__/exports.use-shared-cache.stories.mdx +30 -0
- package/src/__docs__/exports.when-client-side.stories.mdx +33 -0
- package/src/__docs__/types.cached-response.stories.mdx +29 -0
- package/src/__docs__/types.error-options.stories.mdx +21 -0
- package/src/__docs__/types.gql-context.stories.mdx +20 -0
- package/src/__docs__/types.gql-fetch-fn.stories.mdx +24 -0
- package/src/__docs__/types.gql-fetch-options.stories.mdx +24 -0
- package/src/__docs__/types.gql-operation-type.stories.mdx +24 -0
- package/src/__docs__/types.gql-operation.stories.mdx +67 -0
- package/src/__docs__/types.response-cache.stories.mdx +33 -0
- package/src/__docs__/types.result.stories.mdx +39 -0
- package/src/__docs__/types.scoped-cache.stories.mdx +27 -0
- package/src/__docs__/types.valid-cache-data.stories.mdx +23 -0
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +0 -80
- package/src/__tests__/generated-snapshot.test.js +0 -24
- package/src/components/__tests__/data.test.js +149 -128
- package/src/components/data.js +22 -112
- package/src/components/intercept-requests.js +1 -1
- package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
- package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
- package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
- package/src/hooks/__tests__/use-gql.test.js +1 -30
- package/src/hooks/__tests__/use-hydratable-effect.test.js +705 -0
- package/src/hooks/__tests__/use-server-effect.test.js +90 -11
- package/src/hooks/use-cached-effect.js +225 -0
- package/src/hooks/use-gql-router-context.js +50 -0
- package/src/hooks/use-gql.js +22 -52
- package/src/hooks/use-hydratable-effect.js +206 -0
- package/src/hooks/use-request-interception.js +20 -23
- package/src/hooks/use-server-effect.js +42 -10
- package/src/hooks/use-shared-cache.js +13 -11
- package/src/index.js +53 -3
- package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
- package/src/util/__tests__/merge-gql-context.test.js +74 -0
- package/src/util/__tests__/request-fulfillment.test.js +23 -42
- package/src/util/__tests__/request-tracking.test.js +26 -7
- package/src/util/__tests__/result-from-cache-response.test.js +19 -5
- package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
- package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
- package/src/util/__tests__/ssr-cache.test.js +52 -52
- package/src/util/data-error.js +58 -0
- package/src/util/get-gql-data-from-response.js +3 -2
- package/src/util/gql-error.js +19 -11
- package/src/util/merge-gql-context.js +34 -0
- package/src/util/request-fulfillment.js +49 -46
- package/src/util/request-tracking.js +69 -15
- package/src/util/result-from-cache-response.js +12 -16
- package/src/util/scoped-in-memory-cache.js +24 -47
- package/src/util/serializable-in-memory-cache.js +49 -0
- package/src/util/ssr-cache.js +9 -8
- package/src/util/status.js +30 -0
- package/src/util/types.js +18 -1
- package/docs.md +0 -122
|
@@ -8,23 +8,46 @@ import TrackData from "../../components/track-data.js";
|
|
|
8
8
|
import {RequestFulfillment} from "../../util/request-fulfillment.js";
|
|
9
9
|
import {SsrCache} from "../../util/ssr-cache.js";
|
|
10
10
|
import {RequestTracker} from "../../util/request-tracking.js";
|
|
11
|
+
import {DataError} from "../../util/data-error.js";
|
|
12
|
+
import * as UseRequestInterception from "../use-request-interception.js";
|
|
11
13
|
|
|
12
14
|
import {useServerEffect} from "../use-server-effect.js";
|
|
13
15
|
|
|
16
|
+
jest.mock("../use-request-interception.js");
|
|
17
|
+
|
|
14
18
|
describe("#useServerEffect", () => {
|
|
15
19
|
beforeEach(() => {
|
|
20
|
+
jest.resetAllMocks();
|
|
21
|
+
|
|
16
22
|
const responseCache = new SsrCache();
|
|
17
23
|
jest.spyOn(SsrCache, "Default", "get").mockReturnValue(responseCache);
|
|
18
24
|
jest.spyOn(RequestFulfillment, "Default", "get").mockReturnValue(
|
|
19
|
-
new RequestFulfillment(
|
|
25
|
+
new RequestFulfillment(),
|
|
20
26
|
);
|
|
21
27
|
jest.spyOn(RequestTracker, "Default", "get").mockReturnValue(
|
|
22
28
|
new RequestTracker(responseCache),
|
|
23
29
|
);
|
|
30
|
+
|
|
31
|
+
// Simple implementation of request interception that just returns
|
|
32
|
+
// the handler.
|
|
33
|
+
jest.spyOn(
|
|
34
|
+
UseRequestInterception,
|
|
35
|
+
"useRequestInterception",
|
|
36
|
+
).mockImplementation((_, handler) => handler);
|
|
24
37
|
});
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
it("should call useRequestInterception", () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const useRequestInterceptSpy = jest
|
|
42
|
+
.spyOn(UseRequestInterception, "useRequestInterception")
|
|
43
|
+
.mockReturnValue(jest.fn());
|
|
44
|
+
const fakeHandler = jest.fn();
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
serverRenderHook(() => useServerEffect("ID", fakeHandler));
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(useRequestInterceptSpy).toHaveBeenCalledWith("ID", fakeHandler);
|
|
28
51
|
});
|
|
29
52
|
|
|
30
53
|
describe("when server-side", () => {
|
|
@@ -60,9 +83,14 @@ describe("#useServerEffect", () => {
|
|
|
60
83
|
expect(fulfillRequestSpy).not.toHaveBeenCalled();
|
|
61
84
|
});
|
|
62
85
|
|
|
63
|
-
it("should track the request", () => {
|
|
86
|
+
it("should track the intercepted request", () => {
|
|
64
87
|
// Arrange
|
|
65
88
|
const fakeHandler = jest.fn();
|
|
89
|
+
const interceptedHandler = jest.fn();
|
|
90
|
+
jest.spyOn(
|
|
91
|
+
UseRequestInterception,
|
|
92
|
+
"useRequestInterception",
|
|
93
|
+
).mockReturnValue(interceptedHandler);
|
|
66
94
|
const trackDataRequestSpy = jest.spyOn(
|
|
67
95
|
RequestTracker.Default,
|
|
68
96
|
"trackDataRequest",
|
|
@@ -76,11 +104,62 @@ describe("#useServerEffect", () => {
|
|
|
76
104
|
// Assert
|
|
77
105
|
expect(trackDataRequestSpy).toHaveBeenCalledWith(
|
|
78
106
|
"ID",
|
|
79
|
-
|
|
107
|
+
interceptedHandler,
|
|
80
108
|
true,
|
|
81
109
|
);
|
|
82
110
|
});
|
|
83
111
|
|
|
112
|
+
it("should not track the intercepted request if skip is true", () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const fakeHandler = jest.fn();
|
|
115
|
+
const interceptedHandler = jest.fn();
|
|
116
|
+
jest.spyOn(
|
|
117
|
+
UseRequestInterception,
|
|
118
|
+
"useRequestInterception",
|
|
119
|
+
).mockReturnValue(interceptedHandler);
|
|
120
|
+
const trackDataRequestSpy = jest.spyOn(
|
|
121
|
+
RequestTracker.Default,
|
|
122
|
+
"trackDataRequest",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
serverRenderHook(
|
|
127
|
+
() => useServerEffect("ID", fakeHandler, {skip: true}),
|
|
128
|
+
{
|
|
129
|
+
wrapper: TrackData,
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
expect(trackDataRequestSpy).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should not track the intercepted request if there is a cached result", () => {
|
|
138
|
+
// Arrange
|
|
139
|
+
const fakeHandler = jest.fn();
|
|
140
|
+
const interceptedHandler = jest.fn();
|
|
141
|
+
jest.spyOn(SsrCache.Default, "getEntry").mockReturnValueOnce({
|
|
142
|
+
data: "DATA",
|
|
143
|
+
error: null,
|
|
144
|
+
});
|
|
145
|
+
jest.spyOn(
|
|
146
|
+
UseRequestInterception,
|
|
147
|
+
"useRequestInterception",
|
|
148
|
+
).mockReturnValue(interceptedHandler);
|
|
149
|
+
const trackDataRequestSpy = jest.spyOn(
|
|
150
|
+
RequestTracker.Default,
|
|
151
|
+
"trackDataRequest",
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Act
|
|
155
|
+
serverRenderHook(() => useServerEffect("ID", fakeHandler), {
|
|
156
|
+
wrapper: TrackData,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
160
|
+
expect(trackDataRequestSpy).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
84
163
|
it("should return data cached result", () => {
|
|
85
164
|
// Arrange
|
|
86
165
|
const fakeHandler = jest.fn();
|
|
@@ -95,7 +174,7 @@ describe("#useServerEffect", () => {
|
|
|
95
174
|
} = serverRenderHook(() => useServerEffect("ID", fakeHandler));
|
|
96
175
|
|
|
97
176
|
// Assert
|
|
98
|
-
expect(result).toEqual({
|
|
177
|
+
expect(result).toEqual({status: "success", data: "DATA"});
|
|
99
178
|
});
|
|
100
179
|
|
|
101
180
|
it("should return error cached result", () => {
|
|
@@ -113,8 +192,8 @@ describe("#useServerEffect", () => {
|
|
|
113
192
|
|
|
114
193
|
// Assert
|
|
115
194
|
expect(result).toEqual({
|
|
116
|
-
|
|
117
|
-
error:
|
|
195
|
+
status: "error",
|
|
196
|
+
error: expect.any(DataError),
|
|
118
197
|
});
|
|
119
198
|
});
|
|
120
199
|
});
|
|
@@ -151,7 +230,7 @@ describe("#useServerEffect", () => {
|
|
|
151
230
|
} = clientRenderHook(() => useServerEffect("ID", fakeHandler));
|
|
152
231
|
|
|
153
232
|
// Assert
|
|
154
|
-
expect(result).toEqual({
|
|
233
|
+
expect(result).toEqual({status: "success", data: "DATA"});
|
|
155
234
|
});
|
|
156
235
|
|
|
157
236
|
it("should return error cached result", () => {
|
|
@@ -169,8 +248,8 @@ describe("#useServerEffect", () => {
|
|
|
169
248
|
|
|
170
249
|
// Assert
|
|
171
250
|
expect(result).toEqual({
|
|
172
|
-
|
|
173
|
-
error:
|
|
251
|
+
status: "error",
|
|
252
|
+
error: expect.any(DataError),
|
|
174
253
|
});
|
|
175
254
|
});
|
|
176
255
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {useForceUpdate} from "@khanacademy/wonder-blocks-core";
|
|
4
|
+
|
|
5
|
+
import {RequestFulfillment} from "../util/request-fulfillment.js";
|
|
6
|
+
import {Status} from "../util/status.js";
|
|
7
|
+
|
|
8
|
+
import {useSharedCache} from "./use-shared-cache.js";
|
|
9
|
+
import {useRequestInterception} from "./use-request-interception.js";
|
|
10
|
+
|
|
11
|
+
import type {Result, ValidCacheData} from "../util/types.js";
|
|
12
|
+
|
|
13
|
+
type CachedEffectOptions<TData: ValidCacheData> = {|
|
|
14
|
+
/**
|
|
15
|
+
* When `true`, the effect will not be executed; otherwise, the effect will
|
|
16
|
+
* be executed.
|
|
17
|
+
*
|
|
18
|
+
* If this is set to `true` while the effect is still pending, the pending
|
|
19
|
+
* effect will be cancelled.
|
|
20
|
+
*
|
|
21
|
+
* Default is `false`.
|
|
22
|
+
*/
|
|
23
|
+
skip?: boolean,
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* When `true`, the effect will not reset the result to the loading status
|
|
27
|
+
* while executing if the requestId changes, instead, returning
|
|
28
|
+
* the existing result from before the change; otherwise, the result will
|
|
29
|
+
* be set to loading status.
|
|
30
|
+
*
|
|
31
|
+
* If the status is loading when the changes are made, it will remain as
|
|
32
|
+
* loading; old pending effects are discarded on changes and as such this
|
|
33
|
+
* value has no effect in that case.
|
|
34
|
+
*/
|
|
35
|
+
retainResultOnChange?: boolean,
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Callback that is invoked if the result for the given hook has changed.
|
|
39
|
+
*
|
|
40
|
+
* When defined, the hook will invoke this callback whenever it has reason
|
|
41
|
+
* to change the result and will not otherwise affect component rendering
|
|
42
|
+
* directly.
|
|
43
|
+
*
|
|
44
|
+
* When not defined, the hook will ensure the component re-renders to pick
|
|
45
|
+
* up the latest result.
|
|
46
|
+
*/
|
|
47
|
+
onResultChanged?: (result: Result<TData>) => void,
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scope to use with the shared cache.
|
|
51
|
+
*
|
|
52
|
+
* When specified, the given scope will be used to isolate this hook's
|
|
53
|
+
* cached results. Otherwise, a shared default scope will be used.
|
|
54
|
+
*
|
|
55
|
+
* Changing this value after the first call is not supported.
|
|
56
|
+
*/
|
|
57
|
+
scope?: string,
|
|
58
|
+
|};
|
|
59
|
+
|
|
60
|
+
const DefaultScope = "useCachedEffect";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hook to execute and cache an async operation on the client.
|
|
64
|
+
*
|
|
65
|
+
* This hook executes the given handler on the client if there is no
|
|
66
|
+
* cached result to use.
|
|
67
|
+
*
|
|
68
|
+
* Results are cached so they can be shared between equivalent invocations.
|
|
69
|
+
* In-flight requests are also shared, so that concurrent calls will
|
|
70
|
+
* behave as one might exect. Cache updates invoked by one hook instance
|
|
71
|
+
* do not trigger renders in components that use the same requestID; however,
|
|
72
|
+
* that should not matter since concurrent requests will share the same
|
|
73
|
+
* in-flight request, and subsequent renders will grab from the cache.
|
|
74
|
+
*
|
|
75
|
+
* Once the request has been tried once and a non-loading response has been
|
|
76
|
+
* cached, the request will not executed made again.
|
|
77
|
+
*/
|
|
78
|
+
export const useCachedEffect = <TData: ValidCacheData>(
|
|
79
|
+
requestId: string,
|
|
80
|
+
handler: () => Promise<TData>,
|
|
81
|
+
options: CachedEffectOptions<TData> = ({}: $Shape<
|
|
82
|
+
CachedEffectOptions<TData>,
|
|
83
|
+
>),
|
|
84
|
+
): Result<TData> => {
|
|
85
|
+
const {
|
|
86
|
+
skip: hardSkip = false,
|
|
87
|
+
retainResultOnChange = false,
|
|
88
|
+
onResultChanged,
|
|
89
|
+
scope = DefaultScope,
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
// Plug in to the request interception framework for code that wants
|
|
93
|
+
// to use that.
|
|
94
|
+
const interceptedHandler = useRequestInterception(requestId, handler);
|
|
95
|
+
|
|
96
|
+
// Instead of using state, which would be local to just this hook instance,
|
|
97
|
+
// we use a shared in-memory cache.
|
|
98
|
+
const [mostRecentResult, setMostRecentResult] = useSharedCache<
|
|
99
|
+
Result<TData>,
|
|
100
|
+
>(
|
|
101
|
+
requestId, // The key of the cached item
|
|
102
|
+
scope, // The scope of the cached items
|
|
103
|
+
// No default value. We don't want the loading status there; to ensure
|
|
104
|
+
// that all calls when the request is in-flight will update once that
|
|
105
|
+
// request is done, we want the cache to be empty until that point.
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Build a function that will update the cache and either invoke the
|
|
109
|
+
// callback provided in options, or force an update.
|
|
110
|
+
const forceUpdate = useForceUpdate();
|
|
111
|
+
const setCacheAndNotify = React.useCallback(
|
|
112
|
+
(value: Result<TData>) => {
|
|
113
|
+
setMostRecentResult(value);
|
|
114
|
+
|
|
115
|
+
// If our caller provided a cacheUpdated callback, we use that.
|
|
116
|
+
// Otherwise, we toggle our little state update.
|
|
117
|
+
if (onResultChanged != null) {
|
|
118
|
+
onResultChanged(value);
|
|
119
|
+
} else {
|
|
120
|
+
forceUpdate();
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
[setMostRecentResult, onResultChanged, forceUpdate],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// We need to trigger a re-render when the request ID changes as that
|
|
127
|
+
// indicates its a different request. We don't default the current id as
|
|
128
|
+
// this is a proxy for the first render, where we will make the request
|
|
129
|
+
// if we don't already have a cached value.
|
|
130
|
+
const requestIdRef = React.useRef();
|
|
131
|
+
const previousRequestId = requestIdRef.current;
|
|
132
|
+
|
|
133
|
+
// Calculate our soft skip state.
|
|
134
|
+
// Soft skip changes are things that should skip the effect if something
|
|
135
|
+
// else triggers the effect to run, but should not itself trigger the effect
|
|
136
|
+
// (which would cancel a previous invocation).
|
|
137
|
+
const softSkip = React.useMemo(() => {
|
|
138
|
+
if (requestId === previousRequestId) {
|
|
139
|
+
// If the requestId is unchanged, it means we already rendered at
|
|
140
|
+
// least once and so we already made the request at least once. So
|
|
141
|
+
// we can bail out right here.
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If we already have a cached value, we're going to skip.
|
|
146
|
+
if (mostRecentResult != null) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
}, [requestId, previousRequestId, mostRecentResult]);
|
|
152
|
+
|
|
153
|
+
// So now we make sure the client-side request happens per our various
|
|
154
|
+
// options.
|
|
155
|
+
React.useEffect(() => {
|
|
156
|
+
let cancel = false;
|
|
157
|
+
|
|
158
|
+
// We don't do anything if we've been told to hard skip (a hard skip
|
|
159
|
+
// means we should cancel the previous request and is therefore a
|
|
160
|
+
// dependency on that), or we have determined we have already done
|
|
161
|
+
// enough and can soft skip (a soft skip doesn't trigger the request
|
|
162
|
+
// to re-run; we don't want to cancel the in progress effect if we're
|
|
163
|
+
// soft skipping.
|
|
164
|
+
if (hardSkip || softSkip) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If we got here, we're going to perform the request.
|
|
169
|
+
// Let's make sure our ref is set to the most recent requestId.
|
|
170
|
+
requestIdRef.current = requestId;
|
|
171
|
+
|
|
172
|
+
// OK, we've done all our checks and things. It's time to make the
|
|
173
|
+
// request. We use our request fulfillment here so that in-flight
|
|
174
|
+
// requests are shared.
|
|
175
|
+
// NOTE: Our request fulfillment handles the error cases here.
|
|
176
|
+
// Catching shouldn't serve a purpose.
|
|
177
|
+
// eslint-disable-next-line promise/catch-or-return
|
|
178
|
+
RequestFulfillment.Default.fulfill(requestId, {
|
|
179
|
+
handler: interceptedHandler,
|
|
180
|
+
}).then((result) => {
|
|
181
|
+
if (cancel) {
|
|
182
|
+
// We don't modify our result if an earlier effect was
|
|
183
|
+
// cancelled as it means that this hook no longer cares about
|
|
184
|
+
// that old request.
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
setCacheAndNotify(result);
|
|
189
|
+
return; // Shut up eslint always-return rule.
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return () => {
|
|
193
|
+
// TODO(somewhatabstract, FEI-4276): Eventually, we will want to be
|
|
194
|
+
// able abort in-flight requests, but for now, we don't have that.
|
|
195
|
+
// (Of course, we will only want to abort them if no one is waiting
|
|
196
|
+
// on them)
|
|
197
|
+
// For now, we just block cancelled requests from changing our
|
|
198
|
+
// cache.
|
|
199
|
+
cancel = true;
|
|
200
|
+
};
|
|
201
|
+
// We only want to run this effect if the requestId, or skip values
|
|
202
|
+
// change. These are the only two things that should affect the
|
|
203
|
+
// cancellation of a pending request. We do not update if the handler
|
|
204
|
+
// changes, in order to simplify the API - otherwise, callers would
|
|
205
|
+
// not be able to use inline functions with this hook.
|
|
206
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
|
+
}, [hardSkip, requestId]);
|
|
208
|
+
|
|
209
|
+
// We track the last result we returned in order to support the
|
|
210
|
+
// "retainResultOnChange" option.
|
|
211
|
+
const lastResultAgnosticOfIdRef = React.useRef(Status.loading());
|
|
212
|
+
const loadingResult = retainResultOnChange
|
|
213
|
+
? lastResultAgnosticOfIdRef.current
|
|
214
|
+
: Status.loading();
|
|
215
|
+
|
|
216
|
+
// Loading is a transient state, so we only use it here; it's not something
|
|
217
|
+
// we cache.
|
|
218
|
+
const result = React.useMemo(
|
|
219
|
+
() => mostRecentResult ?? loadingResult,
|
|
220
|
+
[mostRecentResult, loadingResult],
|
|
221
|
+
);
|
|
222
|
+
lastResultAgnosticOfIdRef.current = result;
|
|
223
|
+
|
|
224
|
+
return result;
|
|
225
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {useContext, useRef, useMemo} from "react";
|
|
3
|
+
|
|
4
|
+
import {mergeGqlContext} from "../util/merge-gql-context.js";
|
|
5
|
+
import {GqlRouterContext} from "../util/gql-router-context.js";
|
|
6
|
+
import {GqlError, GqlErrors} from "../util/gql-error.js";
|
|
7
|
+
|
|
8
|
+
import type {GqlRouterConfiguration, GqlContext} from "../util/gql-types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Construct a GqlRouterContext from the current one and partial context.
|
|
12
|
+
*/
|
|
13
|
+
export const useGqlRouterContext = <TContext: GqlContext>(
|
|
14
|
+
contextOverrides: Partial<TContext> = ({}: $Shape<TContext>),
|
|
15
|
+
): GqlRouterConfiguration<TContext> => {
|
|
16
|
+
// This hook only works if the `GqlRouter` has been used to setup context.
|
|
17
|
+
const gqlRouterContext = useContext(GqlRouterContext);
|
|
18
|
+
if (gqlRouterContext == null) {
|
|
19
|
+
throw new GqlError("No GqlRouter", GqlErrors.Internal);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {fetch, defaultContext} = gqlRouterContext;
|
|
23
|
+
const contextRef = useRef<TContext>(defaultContext);
|
|
24
|
+
const mergedContext = mergeGqlContext(defaultContext, contextOverrides);
|
|
25
|
+
|
|
26
|
+
// Now, we can see if this represents a new context and if so,
|
|
27
|
+
// update our ref and return the merged value.
|
|
28
|
+
const refKeys = Object.keys(contextRef.current);
|
|
29
|
+
const mergedKeys = Object.keys(mergedContext);
|
|
30
|
+
const shouldWeUpdateRef =
|
|
31
|
+
refKeys.length !== mergedKeys.length ||
|
|
32
|
+
mergedKeys.every(
|
|
33
|
+
(key) => contextRef.current[key] !== mergedContext[key],
|
|
34
|
+
);
|
|
35
|
+
if (shouldWeUpdateRef) {
|
|
36
|
+
contextRef.current = mergedContext;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// OK, now we're up-to-date, let's memoize our final result.
|
|
40
|
+
const finalContext = contextRef.current;
|
|
41
|
+
const finalRouterContext = useMemo(
|
|
42
|
+
() => ({
|
|
43
|
+
fetch,
|
|
44
|
+
defaultContext: finalContext,
|
|
45
|
+
}),
|
|
46
|
+
[fetch, finalContext],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return finalRouterContext;
|
|
50
|
+
};
|
package/src/hooks/use-gql.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @flow
|
|
2
|
-
import {
|
|
2
|
+
import {useCallback} from "react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {mergeGqlContext} from "../util/merge-gql-context.js";
|
|
5
|
+
import {useGqlRouterContext} from "./use-gql-router-context.js";
|
|
5
6
|
import {getGqlDataFromResponse} from "../util/get-gql-data-from-response.js";
|
|
6
|
-
import {GqlError, GqlErrors} from "../util/gql-error.js";
|
|
7
7
|
|
|
8
8
|
import type {
|
|
9
9
|
GqlContext,
|
|
@@ -21,65 +21,35 @@ import type {
|
|
|
21
21
|
* Values in the partial context given to the returned fetch function will
|
|
22
22
|
* only be included if they have a value other than undefined.
|
|
23
23
|
*/
|
|
24
|
-
export const useGql =
|
|
24
|
+
export const useGql = <TContext: GqlContext>(
|
|
25
|
+
context: Partial<TContext> = ({}: $Shape<TContext>),
|
|
26
|
+
): (<TData, TVariables: {...}>(
|
|
25
27
|
operation: GqlOperation<TData, TVariables>,
|
|
26
28
|
options?: GqlFetchOptions<TVariables, TContext>,
|
|
27
|
-
) => Promise
|
|
29
|
+
) => Promise<TData>) => {
|
|
28
30
|
// This hook only works if the `GqlRouter` has been used to setup context.
|
|
29
|
-
const gqlRouterContext =
|
|
30
|
-
if (gqlRouterContext == null) {
|
|
31
|
-
throw new GqlError("No GqlRouter", GqlErrors.Internal);
|
|
32
|
-
}
|
|
33
|
-
const {fetch, defaultContext} = gqlRouterContext;
|
|
31
|
+
const gqlRouterContext = useGqlRouterContext(context);
|
|
34
32
|
|
|
35
33
|
// Let's memoize the gqlFetch function we create based off our context.
|
|
36
34
|
// That way, even if the context happens to change, if its values don't
|
|
37
35
|
// we give the same function instance back to our callers instead of
|
|
38
36
|
// making a new one. That then means they can safely use the return value
|
|
39
37
|
// in hooks deps without fear of it triggering extra renders.
|
|
40
|
-
const gqlFetch =
|
|
41
|
-
(
|
|
42
|
-
<TData, TVariables
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const {variables, context = {}} = options;
|
|
38
|
+
const gqlFetch = useCallback(
|
|
39
|
+
<TData, TVariables: {...}>(
|
|
40
|
+
operation: GqlOperation<TData, TVariables>,
|
|
41
|
+
options: GqlFetchOptions<TVariables, TContext> = Object.freeze({}),
|
|
42
|
+
) => {
|
|
43
|
+
const {fetch, defaultContext} = gqlRouterContext;
|
|
44
|
+
const {variables, context = {}} = options;
|
|
45
|
+
const finalContext = mergeGqlContext(defaultContext, context);
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
(acc, key) => {
|
|
57
|
-
if (context[key] !== undefined) {
|
|
58
|
-
acc[key] = context[key];
|
|
59
|
-
}
|
|
60
|
-
return acc;
|
|
61
|
-
},
|
|
62
|
-
{...defaultContext},
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
// Invoke the fetch and extract the data.
|
|
66
|
-
return fetch(operation, variables, mergedContext).then(
|
|
67
|
-
getGqlDataFromResponse,
|
|
68
|
-
(error) => {
|
|
69
|
-
// Return null if the request was aborted.
|
|
70
|
-
// The only way to detect this reliably, it seems, is to
|
|
71
|
-
// check the error name and see if it's "AbortError" (this
|
|
72
|
-
// is also what Apollo does).
|
|
73
|
-
// Even then, it's reliant on the fetch supporting aborts.
|
|
74
|
-
if (error.name === "AbortError") {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
// Need to make sure we pass other errors along.
|
|
78
|
-
throw error;
|
|
79
|
-
},
|
|
80
|
-
);
|
|
81
|
-
},
|
|
82
|
-
[fetch, defaultContext],
|
|
47
|
+
// Invoke the fetch and extract the data.
|
|
48
|
+
return fetch(operation, variables, finalContext).then(
|
|
49
|
+
getGqlDataFromResponse,
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
[gqlRouterContext],
|
|
83
53
|
);
|
|
84
54
|
return gqlFetch;
|
|
85
55
|
};
|