@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/es/index.js +767 -371
  3. package/dist/index.js +1194 -564
  4. package/legacy-docs.md +3 -0
  5. package/package.json +2 -2
  6. package/src/__docs__/_overview_.stories.mdx +18 -0
  7. package/src/__docs__/_overview_graphql.stories.mdx +35 -0
  8. package/src/__docs__/_overview_ssr_.stories.mdx +185 -0
  9. package/src/__docs__/_overview_testing_.stories.mdx +123 -0
  10. package/src/__docs__/exports.clear-shared-cache.stories.mdx +20 -0
  11. package/src/__docs__/exports.data-error.stories.mdx +23 -0
  12. package/src/__docs__/exports.data-errors.stories.mdx +23 -0
  13. package/src/{components/data.md → __docs__/exports.data.stories.mdx} +15 -18
  14. package/src/__docs__/exports.fulfill-all-data-requests.stories.mdx +24 -0
  15. package/src/__docs__/exports.gql-error.stories.mdx +23 -0
  16. package/src/__docs__/exports.gql-errors.stories.mdx +20 -0
  17. package/src/__docs__/exports.gql-router.stories.mdx +29 -0
  18. package/src/__docs__/exports.has-unfulfilled-requests.stories.mdx +20 -0
  19. package/src/{components/intercept-requests.md → __docs__/exports.intercept-requests.stories.mdx} +16 -1
  20. package/src/__docs__/exports.intialize-cache.stories.mdx +29 -0
  21. package/src/__docs__/exports.remove-all-from-cache.stories.mdx +24 -0
  22. package/src/__docs__/exports.remove-from-cache.stories.mdx +25 -0
  23. package/src/__docs__/exports.request-fulfillment.stories.mdx +36 -0
  24. package/src/__docs__/exports.scoped-in-memory-cache.stories.mdx +92 -0
  25. package/src/__docs__/exports.serializable-in-memory-cache.stories.mdx +112 -0
  26. package/src/__docs__/exports.status.stories.mdx +31 -0
  27. package/src/{components/track-data.md → __docs__/exports.track-data.stories.mdx} +15 -0
  28. package/src/__docs__/exports.use-cached-effect.stories.mdx +41 -0
  29. package/src/__docs__/exports.use-gql.stories.mdx +73 -0
  30. package/src/__docs__/exports.use-hydratable-effect.stories.mdx +43 -0
  31. package/src/__docs__/exports.use-server-effect.stories.mdx +50 -0
  32. package/src/__docs__/exports.use-shared-cache.stories.mdx +30 -0
  33. package/src/__docs__/exports.when-client-side.stories.mdx +33 -0
  34. package/src/__docs__/types.cached-response.stories.mdx +29 -0
  35. package/src/__docs__/types.error-options.stories.mdx +21 -0
  36. package/src/__docs__/types.gql-context.stories.mdx +20 -0
  37. package/src/__docs__/types.gql-fetch-fn.stories.mdx +24 -0
  38. package/src/__docs__/types.gql-fetch-options.stories.mdx +24 -0
  39. package/src/__docs__/types.gql-operation-type.stories.mdx +24 -0
  40. package/src/__docs__/types.gql-operation.stories.mdx +67 -0
  41. package/src/__docs__/types.response-cache.stories.mdx +33 -0
  42. package/src/__docs__/types.result.stories.mdx +39 -0
  43. package/src/__docs__/types.scoped-cache.stories.mdx +27 -0
  44. package/src/__docs__/types.valid-cache-data.stories.mdx +23 -0
  45. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +0 -80
  46. package/src/__tests__/generated-snapshot.test.js +0 -24
  47. package/src/components/__tests__/data.test.js +149 -128
  48. package/src/components/data.js +22 -112
  49. package/src/components/intercept-requests.js +1 -1
  50. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
  51. package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
  52. package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
  53. package/src/hooks/__tests__/use-gql.test.js +1 -30
  54. package/src/hooks/__tests__/use-hydratable-effect.test.js +705 -0
  55. package/src/hooks/__tests__/use-server-effect.test.js +90 -11
  56. package/src/hooks/use-cached-effect.js +225 -0
  57. package/src/hooks/use-gql-router-context.js +50 -0
  58. package/src/hooks/use-gql.js +22 -52
  59. package/src/hooks/use-hydratable-effect.js +206 -0
  60. package/src/hooks/use-request-interception.js +20 -23
  61. package/src/hooks/use-server-effect.js +42 -10
  62. package/src/hooks/use-shared-cache.js +13 -11
  63. package/src/index.js +53 -3
  64. package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
  65. package/src/util/__tests__/merge-gql-context.test.js +74 -0
  66. package/src/util/__tests__/request-fulfillment.test.js +23 -42
  67. package/src/util/__tests__/request-tracking.test.js +26 -7
  68. package/src/util/__tests__/result-from-cache-response.test.js +19 -5
  69. package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
  70. package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
  71. package/src/util/__tests__/ssr-cache.test.js +52 -52
  72. package/src/util/data-error.js +58 -0
  73. package/src/util/get-gql-data-from-response.js +3 -2
  74. package/src/util/gql-error.js +19 -11
  75. package/src/util/merge-gql-context.js +34 -0
  76. package/src/util/request-fulfillment.js +49 -46
  77. package/src/util/request-tracking.js +69 -15
  78. package/src/util/result-from-cache-response.js +12 -16
  79. package/src/util/scoped-in-memory-cache.js +24 -47
  80. package/src/util/serializable-in-memory-cache.js +49 -0
  81. package/src/util/ssr-cache.js +9 -8
  82. package/src/util/status.js +30 -0
  83. package/src/util/types.js +18 -1
  84. 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(responseCache),
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
- afterEach(() => {
27
- jest.resetAllMocks();
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
- fakeHandler,
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({data: "DATA", error: null});
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
- data: null,
117
- error: "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({data: "DATA", error: null});
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
- data: null,
173
- error: "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
+ };
@@ -1,9 +1,9 @@
1
1
  // @flow
2
- import {useContext, useMemo} from "react";
2
+ import {useCallback} from "react";
3
3
 
4
- import {GqlRouterContext} from "../util/gql-router-context.js";
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 = (): (<TData, TVariables: {...}, TContext: GqlContext>(
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<?TData>) => {
29
+ ) => Promise<TData>) => {
28
30
  // This hook only works if the `GqlRouter` has been used to setup context.
29
- const gqlRouterContext = useContext(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 = useMemo(
41
- () =>
42
- <TData, TVariables: {...}, TContext: GqlContext>(
43
- operation: GqlOperation<TData, TVariables>,
44
- options: GqlFetchOptions<TVariables, TContext> = Object.freeze(
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
- // Let's merge the partial context of the fetch with the
51
- // default context. We deliberately don't spread because
52
- // spreading would overwrite default context values with
53
- // undefined if the partial context includes a value explicitly
54
- // set to undefined. Instead, we use a map/reduce of keys.
55
- const mergedContext = Object.keys(context).reduce(
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
  };