@khanacademy/wonder-blocks-data 4.0.0 → 6.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 (91) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/es/index.js +793 -375
  3. package/dist/index.js +1203 -523
  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/__docs__/exports.intercept-requests.stories.mdx +69 -0
  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 +38 -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 +7 -31
  47. package/src/components/__tests__/data.test.js +160 -154
  48. package/src/components/__tests__/intercept-requests.test.js +58 -0
  49. package/src/components/data.js +22 -126
  50. package/src/components/intercept-context.js +4 -5
  51. package/src/components/intercept-requests.js +69 -0
  52. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
  53. package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
  54. package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
  55. package/src/hooks/__tests__/use-gql.test.js +1 -30
  56. package/src/hooks/__tests__/use-hydratable-effect.test.js +708 -0
  57. package/src/hooks/__tests__/use-request-interception.test.js +255 -0
  58. package/src/hooks/__tests__/use-server-effect.test.js +39 -11
  59. package/src/hooks/use-cached-effect.js +225 -0
  60. package/src/hooks/use-gql-router-context.js +50 -0
  61. package/src/hooks/use-gql.js +22 -52
  62. package/src/hooks/use-hydratable-effect.js +206 -0
  63. package/src/hooks/use-request-interception.js +51 -0
  64. package/src/hooks/use-server-effect.js +14 -7
  65. package/src/hooks/use-shared-cache.js +13 -11
  66. package/src/index.js +54 -2
  67. package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
  68. package/src/util/__tests__/merge-gql-context.test.js +74 -0
  69. package/src/util/__tests__/request-fulfillment.test.js +23 -42
  70. package/src/util/__tests__/request-tracking.test.js +26 -7
  71. package/src/util/__tests__/result-from-cache-response.test.js +19 -5
  72. package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
  73. package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
  74. package/src/util/__tests__/ssr-cache.test.js +52 -52
  75. package/src/util/abort-error.js +15 -0
  76. package/src/util/data-error.js +58 -0
  77. package/src/util/get-gql-data-from-response.js +3 -2
  78. package/src/util/gql-error.js +19 -11
  79. package/src/util/merge-gql-context.js +34 -0
  80. package/src/util/request-fulfillment.js +49 -46
  81. package/src/util/request-tracking.js +69 -15
  82. package/src/util/result-from-cache-response.js +12 -16
  83. package/src/util/scoped-in-memory-cache.js +24 -47
  84. package/src/util/serializable-in-memory-cache.js +49 -0
  85. package/src/util/ssr-cache.js +9 -8
  86. package/src/util/status.js +30 -0
  87. package/src/util/types.js +18 -1
  88. package/docs.md +0 -122
  89. package/src/components/__tests__/intercept-data.test.js +0 -63
  90. package/src/components/intercept-data.js +0 -66
  91. package/src/components/intercept-data.md +0 -51
@@ -0,0 +1,255 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {renderHook} from "@testing-library/react-hooks";
4
+ import InterceptRequests from "../../components/intercept-requests.js";
5
+ import {useRequestInterception} from "../use-request-interception.js";
6
+
7
+ describe("#useRequestInterception", () => {
8
+ it("should return a function", () => {
9
+ // Arrange
10
+
11
+ // Act
12
+ const {
13
+ result: {current: result},
14
+ } = renderHook(() => useRequestInterception("ID", jest.fn()));
15
+
16
+ // Assert
17
+ expect(result).toBeInstanceOf(Function);
18
+ });
19
+
20
+ it("should return the same function if the arguments and context don't change", () => {
21
+ // Arrange
22
+ const handler = jest.fn();
23
+
24
+ // Act
25
+ const wrapper = renderHook(() => useRequestInterception("ID", handler));
26
+ const result1 = wrapper.result.current;
27
+ wrapper.rerender();
28
+ const result2 = wrapper.result.current;
29
+
30
+ // Assert
31
+ expect(result1).toBe(result2);
32
+ });
33
+
34
+ it("should return a new function if the requestId changes", () => {
35
+ // Arrange
36
+ const handler = jest.fn();
37
+
38
+ // Act
39
+ const wrapper = renderHook(
40
+ ({requestId}) => useRequestInterception(requestId, handler),
41
+ {initialProps: {requestId: "ID"}},
42
+ );
43
+ const result1 = wrapper.result.current;
44
+ wrapper.rerender({requestId: "ID2"});
45
+ const result2 = wrapper.result.current;
46
+
47
+ // Assert
48
+ expect(result1).not.toBe(result2);
49
+ });
50
+
51
+ it("should return a new function if the handler changes", () => {
52
+ // Arrange
53
+
54
+ // Act
55
+ const wrapper = renderHook(
56
+ ({handler}) => useRequestInterception("ID", handler),
57
+ {initialProps: {handler: jest.fn()}},
58
+ );
59
+ const result1 = wrapper.result.current;
60
+ wrapper.rerender({handler: jest.fn()});
61
+ const result2 = wrapper.result.current;
62
+
63
+ // Assert
64
+ expect(result1).not.toBe(result2);
65
+ });
66
+
67
+ it("should return a new function if the context changes", () => {
68
+ // Arrange
69
+ const handler = jest.fn();
70
+ const interceptor1 = jest.fn();
71
+ const interceptor2 = jest.fn();
72
+ const Wrapper = ({children, interceptor}: any) => (
73
+ <InterceptRequests interceptor={interceptor}>
74
+ {children}
75
+ </InterceptRequests>
76
+ );
77
+
78
+ // Act
79
+ const wrapper = renderHook(
80
+ () => useRequestInterception("ID", handler),
81
+ {wrapper: Wrapper, initialProps: {interceptor: interceptor1}},
82
+ );
83
+ const result1 = wrapper.result.current;
84
+ wrapper.rerender({wrapper: Wrapper, interceptor: interceptor2});
85
+ const result2 = wrapper.result.current;
86
+
87
+ // Assert
88
+ expect(result1).not.toBe(result2);
89
+ });
90
+
91
+ describe("returned function", () => {
92
+ it("should invoke the original handler when there are no interceptors", () => {
93
+ // Arrange
94
+ const handler = jest.fn();
95
+ const requestId = "ID";
96
+ const {
97
+ result: {current: interceptedHandler},
98
+ } = renderHook(() => useRequestInterception(requestId, handler));
99
+
100
+ // Act
101
+ interceptedHandler();
102
+
103
+ // Assert
104
+ expect(handler).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("should invoke interceptors nearest to furthest", () => {
108
+ // Arrange
109
+ const handler = jest.fn();
110
+ const interceptorFurthest = jest.fn(() => null);
111
+ const interceptorNearest = jest.fn(() => null);
112
+ const Wrapper = ({children}: any) => (
113
+ <InterceptRequests interceptor={interceptorFurthest}>
114
+ <InterceptRequests interceptor={interceptorNearest}>
115
+ {children}
116
+ </InterceptRequests>
117
+ </InterceptRequests>
118
+ );
119
+ const {
120
+ result: {current: interceptedHandler},
121
+ } = renderHook(() => useRequestInterception("ID", handler), {
122
+ wrapper: Wrapper,
123
+ });
124
+
125
+ // Act
126
+ interceptedHandler();
127
+
128
+ // Assert
129
+ expect(interceptorNearest).toHaveBeenCalledBefore(
130
+ interceptorFurthest,
131
+ );
132
+ });
133
+
134
+ it("should invoke the handler last", () => {
135
+ // Arrange
136
+ const handler = jest.fn();
137
+ const interceptorFurthest = jest.fn(() => null);
138
+ const interceptorNearest = jest.fn(() => null);
139
+ const Wrapper = ({children}: any) => (
140
+ <InterceptRequests interceptor={interceptorFurthest}>
141
+ <InterceptRequests interceptor={interceptorNearest}>
142
+ {children}
143
+ </InterceptRequests>
144
+ </InterceptRequests>
145
+ );
146
+ const {
147
+ result: {current: interceptedHandler},
148
+ } = renderHook(() => useRequestInterception("ID", handler), {
149
+ wrapper: Wrapper,
150
+ });
151
+
152
+ // Act
153
+ interceptedHandler();
154
+
155
+ // Assert
156
+ expect(interceptorFurthest).toHaveBeenCalledBefore(handler);
157
+ });
158
+
159
+ it("should invoke the original handler when there all interceptors return null", () => {
160
+ // Arrange
161
+ const handler = jest.fn();
162
+ const interceptor1 = jest.fn(() => null);
163
+ const interceptor2 = jest.fn(() => null);
164
+ const Wrapper = ({children}: any) => (
165
+ <InterceptRequests interceptor={interceptor1}>
166
+ <InterceptRequests interceptor={interceptor2}>
167
+ {children}
168
+ </InterceptRequests>
169
+ </InterceptRequests>
170
+ );
171
+ const {
172
+ result: {current: interceptedHandler},
173
+ } = renderHook(() => useRequestInterception("ID", handler), {
174
+ wrapper: Wrapper,
175
+ });
176
+
177
+ // Act
178
+ interceptedHandler();
179
+
180
+ // Assert
181
+ expect(handler).toHaveBeenCalledTimes(1);
182
+ });
183
+
184
+ it("should return the result of the nearest interceptor that returns a non-null result", async () => {
185
+ // Arrange
186
+ const handler = jest
187
+ .fn()
188
+ .mockRejectedValue(
189
+ new Error("This handler should have been intercepted"),
190
+ );
191
+ const interceptorFurthest = jest
192
+ .fn()
193
+ .mockRejectedValue(
194
+ new Error("This interceptor should not get called"),
195
+ );
196
+ const interceptorNearest = jest
197
+ .fn()
198
+ .mockResolvedValue("INTERCEPTED_DATA");
199
+ const Wrapper = ({children}: any) => (
200
+ <InterceptRequests interceptor={interceptorFurthest}>
201
+ <InterceptRequests interceptor={interceptorNearest}>
202
+ {children}
203
+ </InterceptRequests>
204
+ </InterceptRequests>
205
+ );
206
+ const {
207
+ result: {current: interceptedHandler},
208
+ } = renderHook(() => useRequestInterception("ID", handler), {
209
+ wrapper: Wrapper,
210
+ });
211
+
212
+ // Act
213
+ const result = await interceptedHandler();
214
+
215
+ // Assert
216
+ expect(result).toBe("INTERCEPTED_DATA");
217
+ });
218
+
219
+ it("should not invoke interceptors or handlers beyond a non-null interception", () => {
220
+ // Arrange
221
+ const handler = jest
222
+ .fn()
223
+ .mockRejectedValue(
224
+ new Error("This handler should have been intercepted"),
225
+ );
226
+ const interceptorFurthest = jest
227
+ .fn()
228
+ .mockRejectedValue(
229
+ new Error("This interceptor should not get called"),
230
+ );
231
+ const interceptorNearest = jest
232
+ .fn()
233
+ .mockResolvedValue("INTERCEPTED_DATA");
234
+ const Wrapper = ({children}: any) => (
235
+ <InterceptRequests interceptor={interceptorFurthest}>
236
+ <InterceptRequests interceptor={interceptorNearest}>
237
+ {children}
238
+ </InterceptRequests>
239
+ </InterceptRequests>
240
+ );
241
+ const {
242
+ result: {current: interceptedHandler},
243
+ } = renderHook(() => useRequestInterception("ID", handler), {
244
+ wrapper: Wrapper,
245
+ });
246
+
247
+ // Act
248
+ interceptedHandler();
249
+
250
+ // Assert
251
+ expect(handler).not.toHaveBeenCalled();
252
+ expect(interceptorFurthest).not.toHaveBeenCalled();
253
+ });
254
+ });
255
+ });
@@ -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,7 +104,7 @@ describe("#useServerEffect", () => {
76
104
  // Assert
77
105
  expect(trackDataRequestSpy).toHaveBeenCalledWith(
78
106
  "ID",
79
- fakeHandler,
107
+ interceptedHandler,
80
108
  true,
81
109
  );
82
110
  });
@@ -95,7 +123,7 @@ describe("#useServerEffect", () => {
95
123
  } = serverRenderHook(() => useServerEffect("ID", fakeHandler));
96
124
 
97
125
  // Assert
98
- expect(result).toEqual({data: "DATA", error: null});
126
+ expect(result).toEqual({status: "success", data: "DATA"});
99
127
  });
100
128
 
101
129
  it("should return error cached result", () => {
@@ -113,8 +141,8 @@ describe("#useServerEffect", () => {
113
141
 
114
142
  // Assert
115
143
  expect(result).toEqual({
116
- data: null,
117
- error: "ERROR",
144
+ status: "error",
145
+ error: expect.any(DataError),
118
146
  });
119
147
  });
120
148
  });
@@ -151,7 +179,7 @@ describe("#useServerEffect", () => {
151
179
  } = clientRenderHook(() => useServerEffect("ID", fakeHandler));
152
180
 
153
181
  // Assert
154
- expect(result).toEqual({data: "DATA", error: null});
182
+ expect(result).toEqual({status: "success", data: "DATA"});
155
183
  });
156
184
 
157
185
  it("should return error cached result", () => {
@@ -169,8 +197,8 @@ describe("#useServerEffect", () => {
169
197
 
170
198
  // Assert
171
199
  expect(result).toEqual({
172
- data: null,
173
- error: "ERROR",
200
+ status: "error",
201
+ error: expect.any(DataError),
174
202
  });
175
203
  });
176
204
 
@@ -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
+ };