@khanacademy/wonder-blocks-data 3.1.2 → 5.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 +41 -0
- package/dist/es/index.js +408 -349
- package/dist/index.js +568 -467
- package/docs.md +17 -35
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +7 -46
- package/src/__tests__/generated-snapshot.test.js +60 -126
- package/src/components/__tests__/data.test.js +373 -313
- package/src/components/__tests__/intercept-requests.test.js +58 -0
- package/src/components/data.js +139 -21
- package/src/components/data.md +38 -69
- package/src/components/gql-router.js +1 -1
- package/src/components/intercept-context.js +6 -3
- package/src/components/intercept-requests.js +69 -0
- package/src/components/intercept-requests.md +54 -0
- package/src/components/track-data.md +9 -23
- package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +17 -0
- package/src/hooks/__tests__/use-gql.test.js +1 -0
- package/src/hooks/__tests__/use-request-interception.test.js +255 -0
- package/src/hooks/__tests__/use-server-effect.test.js +217 -0
- package/src/hooks/__tests__/use-shared-cache.test.js +307 -0
- package/src/hooks/use-gql.js +39 -31
- package/src/hooks/use-request-interception.js +54 -0
- package/src/hooks/use-server-effect.js +45 -0
- package/src/hooks/use-shared-cache.js +106 -0
- package/src/index.js +17 -20
- package/src/util/__tests__/__snapshots__/scoped-in-memory-cache.test.js.snap +19 -0
- package/src/util/__tests__/request-fulfillment.test.js +42 -85
- package/src/util/__tests__/request-tracking.test.js +72 -191
- package/src/util/__tests__/{result-from-cache-entry.test.js → result-from-cache-response.test.js} +9 -10
- package/src/util/__tests__/scoped-in-memory-cache.test.js +396 -0
- package/src/util/__tests__/ssr-cache.test.js +639 -0
- package/src/util/gql-types.js +5 -10
- package/src/util/request-fulfillment.js +36 -44
- package/src/util/request-tracking.js +62 -75
- package/src/util/{result-from-cache-entry.js → result-from-cache-response.js} +10 -13
- package/src/util/scoped-in-memory-cache.js +149 -0
- package/src/util/ssr-cache.js +206 -0
- package/src/util/types.js +43 -108
- package/src/components/__tests__/intercept-data.test.js +0 -87
- package/src/components/intercept-data.js +0 -77
- package/src/components/intercept-data.md +0 -65
- package/src/hooks/__tests__/use-data.test.js +0 -826
- package/src/hooks/use-data.js +0 -143
- package/src/util/__tests__/memory-cache.test.js +0 -446
- package/src/util/__tests__/request-handler.test.js +0 -121
- package/src/util/__tests__/response-cache.test.js +0 -879
- package/src/util/memory-cache.js +0 -187
- package/src/util/request-handler.js +0 -42
- package/src/util/request-handler.md +0 -51
- package/src/util/response-cache.js +0 -213
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {renderHook as clientRenderHook} from "@testing-library/react-hooks";
|
|
3
|
+
|
|
4
|
+
import {useSharedCache, clearSharedCache} from "../use-shared-cache.js";
|
|
5
|
+
|
|
6
|
+
describe("#useSharedCache", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
clearSharedCache();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it.each`
|
|
12
|
+
id
|
|
13
|
+
${null}
|
|
14
|
+
${""}
|
|
15
|
+
${5}
|
|
16
|
+
${() => "BOO"}
|
|
17
|
+
`("should throw if the id is $id", ({id}) => {
|
|
18
|
+
// Arrange
|
|
19
|
+
|
|
20
|
+
// Act
|
|
21
|
+
const {result} = clientRenderHook(() => useSharedCache(id, "scope"));
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
24
|
+
expect(result.error).toMatchSnapshot();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it.each`
|
|
28
|
+
scope
|
|
29
|
+
${null}
|
|
30
|
+
${""}
|
|
31
|
+
${5}
|
|
32
|
+
${() => "BOO"}
|
|
33
|
+
`("should throw if the scope is $scope", ({scope}) => {
|
|
34
|
+
// Arrange
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const {result} = clientRenderHook(() => useSharedCache("id", scope));
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
40
|
+
expect(result.error).toMatchSnapshot();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should return a tuple of two items", () => {
|
|
44
|
+
// Arrange
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
const {
|
|
48
|
+
result: {current: result},
|
|
49
|
+
} = clientRenderHook(() => useSharedCache("id", "scope"));
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
52
|
+
expect(result).toBeArrayOfSize(2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("tuple[0] - currentValue", () => {
|
|
56
|
+
it("should be null if nothing is cached", () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
const {
|
|
61
|
+
result: {current: result},
|
|
62
|
+
} = clientRenderHook(() => useSharedCache("id", "scope"));
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(result[0]).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should match initialValue when provided as a non-function", () => {
|
|
69
|
+
// Arrange
|
|
70
|
+
|
|
71
|
+
// Act
|
|
72
|
+
const {
|
|
73
|
+
result: {current: result},
|
|
74
|
+
} = clientRenderHook(() =>
|
|
75
|
+
useSharedCache("id", "scope", "INITIAL VALUE"),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(result[0]).toBe("INITIAL VALUE");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should match the return of initialValue when provided as non-function", () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
|
|
85
|
+
// Act
|
|
86
|
+
const {
|
|
87
|
+
result: {current: result},
|
|
88
|
+
} = clientRenderHook(() =>
|
|
89
|
+
useSharedCache("id", "scope", () => "INITIAL VALUE"),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Assert
|
|
93
|
+
expect(result[0]).toBe("INITIAL VALUE");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("tuple[1] - setValue", () => {
|
|
98
|
+
it("should be a function", () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
const {
|
|
103
|
+
result: {current: result},
|
|
104
|
+
} = clientRenderHook(() => useSharedCache("id", "scope"));
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(result[1]).toBeFunction();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should be the same function if the id and scope remain the same", () => {
|
|
111
|
+
// Arrange
|
|
112
|
+
const wrapper = clientRenderHook(
|
|
113
|
+
({id, scope}) => useSharedCache(id, scope),
|
|
114
|
+
{initialProps: {id: "id", scope: "scope"}},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Act
|
|
118
|
+
wrapper.rerender({
|
|
119
|
+
id: "id",
|
|
120
|
+
scope: "scope",
|
|
121
|
+
});
|
|
122
|
+
const result1 = wrapper.result.all[wrapper.result.all.length - 2];
|
|
123
|
+
const result2 = wrapper.result.current;
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
// $FlowIgnore[prop-missing]
|
|
127
|
+
expect(result1[1]).toBe(result2[1]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should be a new function if the id changes", () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
const wrapper = clientRenderHook(
|
|
133
|
+
({id}) => useSharedCache(id, "scope"),
|
|
134
|
+
{
|
|
135
|
+
initialProps: {id: "id"},
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
wrapper.rerender({id: "new-id"});
|
|
141
|
+
const result1 = wrapper.result.all[wrapper.result.all.length - 2];
|
|
142
|
+
const result2 = wrapper.result.current;
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
145
|
+
// $FlowIgnore[prop-missing]
|
|
146
|
+
expect(result1[1]).not.toBe(result2[1]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should be a new function if the scope changes", () => {
|
|
150
|
+
// Arrange
|
|
151
|
+
const wrapper = clientRenderHook(
|
|
152
|
+
({scope}) => useSharedCache("id", scope),
|
|
153
|
+
{
|
|
154
|
+
initialProps: {scope: "scope"},
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Act
|
|
159
|
+
wrapper.rerender({scope: "new-scope"});
|
|
160
|
+
const result1 = wrapper.result.all[wrapper.result.all.length - 2];
|
|
161
|
+
const result2 = wrapper.result.current;
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
164
|
+
// $FlowIgnore[prop-missing]
|
|
165
|
+
expect(result1[1]).not.toBe(result2[1]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should set the value in the cache", () => {
|
|
169
|
+
// Arrange
|
|
170
|
+
const wrapper = clientRenderHook(() =>
|
|
171
|
+
useSharedCache("id", "scope"),
|
|
172
|
+
);
|
|
173
|
+
const setValue = wrapper.result.current[1];
|
|
174
|
+
|
|
175
|
+
// Act
|
|
176
|
+
setValue("CACHED_VALUE");
|
|
177
|
+
// Rerender so the hook retrieves this new value.
|
|
178
|
+
wrapper.rerender();
|
|
179
|
+
const result = wrapper.result.current[0];
|
|
180
|
+
|
|
181
|
+
// Assert
|
|
182
|
+
expect(result).toBe("CACHED_VALUE");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each`
|
|
186
|
+
value
|
|
187
|
+
${undefined}
|
|
188
|
+
${null}
|
|
189
|
+
`("should purge the value from the cache if $value", ({value}) => {
|
|
190
|
+
// Arrange
|
|
191
|
+
const wrapper = clientRenderHook(() =>
|
|
192
|
+
useSharedCache("id", "scope"),
|
|
193
|
+
);
|
|
194
|
+
const setValue = wrapper.result.current[1];
|
|
195
|
+
setValue("CACHED_VALUE");
|
|
196
|
+
|
|
197
|
+
// Act
|
|
198
|
+
// Rerender so the result has the cached value.
|
|
199
|
+
wrapper.rerender();
|
|
200
|
+
setValue(value);
|
|
201
|
+
// Rerender so the hook retrieves this new value.
|
|
202
|
+
wrapper.rerender();
|
|
203
|
+
const result = wrapper.result.current[0];
|
|
204
|
+
|
|
205
|
+
// Assert
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("should share cache across all uses", () => {
|
|
211
|
+
// Arrange
|
|
212
|
+
const hook1 = clientRenderHook(() => useSharedCache("id", "scope"));
|
|
213
|
+
const hook2 = clientRenderHook(() => useSharedCache("id", "scope"));
|
|
214
|
+
hook1.result.current[1]("VALUE_1");
|
|
215
|
+
|
|
216
|
+
// Act
|
|
217
|
+
hook2.rerender();
|
|
218
|
+
const result = hook2.result.current[0];
|
|
219
|
+
|
|
220
|
+
// Assert
|
|
221
|
+
expect(result).toBe("VALUE_1");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it.each`
|
|
225
|
+
id
|
|
226
|
+
${"id1"}
|
|
227
|
+
${"id2"}
|
|
228
|
+
`("should not share cache if scope is different", ({id}) => {
|
|
229
|
+
// Arrange
|
|
230
|
+
const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1"));
|
|
231
|
+
const hook2 = clientRenderHook(() => useSharedCache(id, "scope2"));
|
|
232
|
+
hook1.result.current[1]("VALUE_1");
|
|
233
|
+
|
|
234
|
+
// Act
|
|
235
|
+
hook2.rerender();
|
|
236
|
+
const result = hook2.result.current[0];
|
|
237
|
+
|
|
238
|
+
// Assert
|
|
239
|
+
expect(result).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it.each`
|
|
243
|
+
scope
|
|
244
|
+
${"scope1"}
|
|
245
|
+
${"scope2"}
|
|
246
|
+
`("should not share cache if id is different", ({scope}) => {
|
|
247
|
+
// Arrange
|
|
248
|
+
const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1"));
|
|
249
|
+
const hook2 = clientRenderHook(() => useSharedCache("id2", scope));
|
|
250
|
+
hook1.result.current[1]("VALUE_1");
|
|
251
|
+
|
|
252
|
+
// Act
|
|
253
|
+
hook2.rerender();
|
|
254
|
+
const result = hook2.result.current[0];
|
|
255
|
+
|
|
256
|
+
// Assert
|
|
257
|
+
expect(result).toBeNull();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("#clearSharedCache", () => {
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
clearSharedCache();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should clear the entire cache if no scope given", () => {
|
|
267
|
+
// Arrange
|
|
268
|
+
const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1"));
|
|
269
|
+
const hook2 = clientRenderHook(() => useSharedCache("id2", "scope2"));
|
|
270
|
+
hook1.result.current[1]("VALUE_1");
|
|
271
|
+
hook2.result.current[1]("VALUE_2");
|
|
272
|
+
// Make sure both hook results include the updated value.
|
|
273
|
+
hook1.rerender();
|
|
274
|
+
hook2.rerender();
|
|
275
|
+
|
|
276
|
+
// Act
|
|
277
|
+
clearSharedCache();
|
|
278
|
+
// Make sure we refresh the hook results.
|
|
279
|
+
hook1.rerender();
|
|
280
|
+
hook2.rerender();
|
|
281
|
+
|
|
282
|
+
// Assert
|
|
283
|
+
expect(hook1.result.current[0]).toBeNull();
|
|
284
|
+
expect(hook2.result.current[0]).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should clear the given scope only", () => {
|
|
288
|
+
// Arrange
|
|
289
|
+
const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1"));
|
|
290
|
+
const hook2 = clientRenderHook(() => useSharedCache("id2", "scope2"));
|
|
291
|
+
hook1.result.current[1]("VALUE_1");
|
|
292
|
+
hook2.result.current[1]("VALUE_2");
|
|
293
|
+
// Make sure both hook results include the updated value.
|
|
294
|
+
hook1.rerender();
|
|
295
|
+
hook2.rerender();
|
|
296
|
+
|
|
297
|
+
// Act
|
|
298
|
+
clearSharedCache("scope2");
|
|
299
|
+
// Make sure we refresh the hook results.
|
|
300
|
+
hook1.rerender();
|
|
301
|
+
hook2.rerender();
|
|
302
|
+
|
|
303
|
+
// Assert
|
|
304
|
+
expect(hook1.result.current[0]).toBe("VALUE_1");
|
|
305
|
+
expect(hook2.result.current[0]).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
});
|
package/src/hooks/use-gql.js
CHANGED
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
GqlContext,
|
|
10
10
|
GqlOperation,
|
|
11
11
|
GqlFetchOptions,
|
|
12
|
-
GqlOperationType,
|
|
13
12
|
} from "../util/gql-types.js";
|
|
14
13
|
|
|
15
14
|
/**
|
|
@@ -17,14 +16,13 @@ import type {
|
|
|
17
16
|
*
|
|
18
17
|
* The fetch function will resolve null if the request was aborted, otherwise
|
|
19
18
|
* it will resolve the data returned by the GraphQL server.
|
|
19
|
+
*
|
|
20
|
+
* Context is merged with the default context provided to the GqlRouter.
|
|
21
|
+
* Values in the partial context given to the returned fetch function will
|
|
22
|
+
* only be included if they have a value other than undefined.
|
|
20
23
|
*/
|
|
21
|
-
export const useGql = (): (<
|
|
22
|
-
|
|
23
|
-
TData,
|
|
24
|
-
TVariables: {...},
|
|
25
|
-
TContext: GqlContext,
|
|
26
|
-
>(
|
|
27
|
-
operation: GqlOperation<TType, TData, TVariables>,
|
|
24
|
+
export const useGql = (): (<TData, TVariables: {...}, TContext: GqlContext>(
|
|
25
|
+
operation: GqlOperation<TData, TVariables>,
|
|
28
26
|
options?: GqlFetchOptions<TVariables, TContext>,
|
|
29
27
|
) => Promise<?TData>) => {
|
|
30
28
|
// This hook only works if the `GqlRouter` has been used to setup context.
|
|
@@ -41,35 +39,45 @@ export const useGql = (): (<
|
|
|
41
39
|
// in hooks deps without fear of it triggering extra renders.
|
|
42
40
|
const gqlFetch = useMemo(
|
|
43
41
|
() =>
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
TData,
|
|
47
|
-
TVariables: {...},
|
|
48
|
-
TContext: GqlContext,
|
|
49
|
-
>(
|
|
50
|
-
operation: GqlOperation<TType, TData, TVariables>,
|
|
42
|
+
<TData, TVariables: {...}, TContext: GqlContext>(
|
|
43
|
+
operation: GqlOperation<TData, TVariables>,
|
|
51
44
|
options: GqlFetchOptions<TVariables, TContext> = Object.freeze(
|
|
52
45
|
{},
|
|
53
46
|
),
|
|
54
47
|
) => {
|
|
55
|
-
const {variables, context} = options;
|
|
48
|
+
const {variables, context = {}} = options;
|
|
49
|
+
|
|
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
|
+
);
|
|
56
64
|
|
|
57
65
|
// Invoke the fetch and extract the data.
|
|
58
|
-
return fetch(operation, variables,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
);
|
|
73
81
|
},
|
|
74
82
|
[fetch, defaultContext],
|
|
75
83
|
);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import InterceptContext from "../components/intercept-context.js";
|
|
5
|
+
import type {ValidCacheData} from "../util/types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Allow request handling to be intercepted.
|
|
9
|
+
*
|
|
10
|
+
* Hook to take a uniquely identified request handler and return a
|
|
11
|
+
* method that will support request interception from the InterceptRequest
|
|
12
|
+
* component.
|
|
13
|
+
*
|
|
14
|
+
* If you want request interception to be supported with `useServerEffect` or
|
|
15
|
+
* any client-side effect that uses the handler, call this first to generate
|
|
16
|
+
* an intercepted handler, and then invoke `useServerEffect` (or other things)
|
|
17
|
+
* with that intercepted handler.
|
|
18
|
+
*/
|
|
19
|
+
export const useRequestInterception = <TData: ValidCacheData>(
|
|
20
|
+
requestId: string,
|
|
21
|
+
handler: () => Promise<?TData>,
|
|
22
|
+
): (() => Promise<?TData>) => {
|
|
23
|
+
// Get the interceptors that have been registered.
|
|
24
|
+
const interceptors = React.useContext(InterceptContext);
|
|
25
|
+
|
|
26
|
+
// Now, we need to create a new handler that will check if the
|
|
27
|
+
// request is intercepted before ultimately calling the original handler
|
|
28
|
+
// if nothing intercepted it.
|
|
29
|
+
// We memoize this so that it only changes if something related to it
|
|
30
|
+
// changes.
|
|
31
|
+
const interceptedHandler = React.useMemo(
|
|
32
|
+
() => (): Promise<?TData> => {
|
|
33
|
+
// Call the interceptors from closest to furthest.
|
|
34
|
+
// If one returns a non-null result, then we keep that.
|
|
35
|
+
const interceptResponse = interceptors.reduceRight(
|
|
36
|
+
(prev, interceptor) => {
|
|
37
|
+
if (prev != null) {
|
|
38
|
+
return prev;
|
|
39
|
+
}
|
|
40
|
+
return interceptor(requestId);
|
|
41
|
+
},
|
|
42
|
+
null,
|
|
43
|
+
);
|
|
44
|
+
// If nothing intercepted this request, invoke the original handler.
|
|
45
|
+
// NOTE: We can't guarantee all interceptors return the same type
|
|
46
|
+
// as our handler, so how can flow know? Let's just suppress that.
|
|
47
|
+
// $FlowFixMe[incompatible-return]
|
|
48
|
+
return interceptResponse ?? handler();
|
|
49
|
+
},
|
|
50
|
+
[handler, interceptors, requestId],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return interceptedHandler;
|
|
54
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {Server} from "@khanacademy/wonder-blocks-core";
|
|
3
|
+
import {useContext} from "react";
|
|
4
|
+
import {TrackerContext} from "../util/request-tracking.js";
|
|
5
|
+
import {SsrCache} from "../util/ssr-cache.js";
|
|
6
|
+
|
|
7
|
+
import type {CachedResponse, ValidCacheData} from "../util/types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hook to perform an asynchronous action during server-side rendering.
|
|
11
|
+
*
|
|
12
|
+
* This hook registers an asynchronous action to be performed during
|
|
13
|
+
* server-side rendering. The action is performed only once, and the result
|
|
14
|
+
* is cached against the given identifier so that subsequent calls return that
|
|
15
|
+
* cached result allowing components to render more of the component.
|
|
16
|
+
*
|
|
17
|
+
* This hook requires the Wonder Blocks Data functionality for resolving
|
|
18
|
+
* pending requests, as well as support for the hydration cache to be
|
|
19
|
+
* embedded into a page so that the result can by hydrated (if that is a
|
|
20
|
+
* requirement).
|
|
21
|
+
*
|
|
22
|
+
* The asynchronous action is never invoked on the client-side.
|
|
23
|
+
*/
|
|
24
|
+
export const useServerEffect = <TData: ValidCacheData>(
|
|
25
|
+
requestId: string,
|
|
26
|
+
handler: () => Promise<?TData>,
|
|
27
|
+
hydrate: boolean = true,
|
|
28
|
+
): ?CachedResponse<TData> => {
|
|
29
|
+
// If we're server-side or hydrating, we'll have a cached entry to use.
|
|
30
|
+
// So we get that and use it to initialize our state.
|
|
31
|
+
// This works in both hydration and SSR because the very first call to
|
|
32
|
+
// this will have cached data in those cases as it will be present on the
|
|
33
|
+
// initial render - and subsequent renders on the client it will be null.
|
|
34
|
+
const cachedResult = SsrCache.Default.getEntry<TData>(requestId);
|
|
35
|
+
|
|
36
|
+
// We only track data requests when we are server-side and we don't
|
|
37
|
+
// already have a result, as given by the cachedData (which is also the
|
|
38
|
+
// initial value for the result state).
|
|
39
|
+
const maybeTrack = useContext(TrackerContext);
|
|
40
|
+
if (cachedResult == null && Server.isServerSide()) {
|
|
41
|
+
maybeTrack?.(requestId, handler, hydrate);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return cachedResult;
|
|
45
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
|
|
4
|
+
import {ScopedInMemoryCache} from "../util/scoped-in-memory-cache.js";
|
|
5
|
+
import type {ValidCacheData} from "../util/types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A function for inserting a value into the cache or clearing it.
|
|
9
|
+
*/
|
|
10
|
+
type CacheValueFn<TValue: ValidCacheData> = (value: ?TValue) => void;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* This is the cache.
|
|
14
|
+
* It's incredibly complex.
|
|
15
|
+
* Very in-memory. So cache. Such complex. Wow.
|
|
16
|
+
*/
|
|
17
|
+
const cache = new ScopedInMemoryCache();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clear the in-memory cache or a single scope within it.
|
|
21
|
+
*/
|
|
22
|
+
export const clearSharedCache = (scope: string = "") => {
|
|
23
|
+
// If we have a valid scope (empty string is falsy), then clear that scope.
|
|
24
|
+
if (scope && typeof scope === "string") {
|
|
25
|
+
cache.purgeScope(scope);
|
|
26
|
+
} else {
|
|
27
|
+
// Just reset the object. This should be sufficient.
|
|
28
|
+
cache.purgeAll();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hook to retrieve data from and store data in an in-memory cache.
|
|
34
|
+
*
|
|
35
|
+
* @returns {[?ReadOnlyCacheValue, CacheValueFn]}
|
|
36
|
+
* Returns an array containing the current cache entry (or undefined), a
|
|
37
|
+
* function to set the cache entry (passing null or undefined to this function
|
|
38
|
+
* will delete the entry).
|
|
39
|
+
*
|
|
40
|
+
* To clear a single scope within the cache or the entire cache,
|
|
41
|
+
* the `clearScopedCache` export is available.
|
|
42
|
+
*
|
|
43
|
+
* NOTE: Unlike useState or useReducer, we don't automatically update folks
|
|
44
|
+
* if the value they reference changes. We might add it later (if we need to),
|
|
45
|
+
* but the likelihood here is that things won't be changing in this cache in a
|
|
46
|
+
* way where we would need that. If we do (and likely only in specific
|
|
47
|
+
* circumstances), we should consider adding a simple boolean useState that can
|
|
48
|
+
* be toggled to cause a rerender whenever the referenced cached data changes
|
|
49
|
+
* so that callers can re-render on cache changes. However, we should make
|
|
50
|
+
* sure this toggling is optional - or we could use a callback argument, to
|
|
51
|
+
* achieve this on an as-needed basis.
|
|
52
|
+
*/
|
|
53
|
+
export const useSharedCache = <TValue: ValidCacheData>(
|
|
54
|
+
id: string,
|
|
55
|
+
scope: string,
|
|
56
|
+
initialValue?: ?TValue | (() => ?TValue),
|
|
57
|
+
): [?TValue, CacheValueFn<TValue>] => {
|
|
58
|
+
// Verify arguments.
|
|
59
|
+
if (!id || typeof id !== "string") {
|
|
60
|
+
throw new KindError(
|
|
61
|
+
"id must be a non-empty string",
|
|
62
|
+
Errors.InvalidInput,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!scope || typeof scope !== "string") {
|
|
67
|
+
throw new KindError(
|
|
68
|
+
"scope must be a non-empty string",
|
|
69
|
+
Errors.InvalidInput,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Memoize our APIs.
|
|
74
|
+
// This one allows callers to set or replace the cached value.
|
|
75
|
+
const cacheValue = React.useMemo(
|
|
76
|
+
() => (value: ?TValue) =>
|
|
77
|
+
value == null
|
|
78
|
+
? cache.purge(scope, id)
|
|
79
|
+
: cache.set(scope, id, value),
|
|
80
|
+
[id, scope],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// We don't memo-ize the current value, just in case the cache was updated
|
|
84
|
+
// since our last run through. Also, our cache does not know what type it
|
|
85
|
+
// stores, so we have to cast it to the type we're exporting. This is a
|
|
86
|
+
// dev time courtesy, rather than a runtime thing.
|
|
87
|
+
// $FlowIgnore[incompatible-type]
|
|
88
|
+
let currentValue: ?TValue = cache.get(scope, id);
|
|
89
|
+
|
|
90
|
+
// If we have an initial value, we need to add it to the cache
|
|
91
|
+
// and use it as our current value.
|
|
92
|
+
if (currentValue == null && initialValue !== undefined) {
|
|
93
|
+
// Get the initial value.
|
|
94
|
+
const value =
|
|
95
|
+
typeof initialValue === "function" ? initialValue() : initialValue;
|
|
96
|
+
|
|
97
|
+
// Update the cache.
|
|
98
|
+
cacheValue(value);
|
|
99
|
+
|
|
100
|
+
// Make sure we return this value as our current value.
|
|
101
|
+
currentValue = value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Now we have everything, let's return it.
|
|
105
|
+
return [currentValue, cacheValue];
|
|
106
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import {Server} from "@khanacademy/wonder-blocks-core";
|
|
3
|
-
import {
|
|
3
|
+
import {SsrCache} from "./util/ssr-cache.js";
|
|
4
4
|
import {RequestTracker} from "./util/request-tracking.js";
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
IRequestHandler,
|
|
7
|
+
ValidCacheData,
|
|
8
|
+
CachedResponse,
|
|
10
9
|
ResponseCache,
|
|
11
10
|
} from "./util/types.js";
|
|
12
11
|
|
|
13
12
|
export type {
|
|
14
|
-
Cache,
|
|
15
|
-
CacheEntry,
|
|
16
|
-
Result,
|
|
17
|
-
IRequestHandler,
|
|
18
13
|
ResponseCache,
|
|
14
|
+
CachedResponse,
|
|
15
|
+
Result,
|
|
16
|
+
ScopedCache,
|
|
19
17
|
} from "./util/types.js";
|
|
20
18
|
|
|
21
19
|
export const initializeCache = (source: ResponseCache): void =>
|
|
22
|
-
|
|
20
|
+
SsrCache.Default.initialize(source);
|
|
23
21
|
|
|
24
22
|
export const fulfillAllDataRequests = (): Promise<ResponseCache> => {
|
|
25
23
|
if (!Server.isServerSide()) {
|
|
@@ -37,24 +35,23 @@ export const hasUnfulfilledRequests = (): boolean => {
|
|
|
37
35
|
return RequestTracker.Default.hasUnfulfilledRequests;
|
|
38
36
|
};
|
|
39
37
|
|
|
40
|
-
export const removeFromCache =
|
|
41
|
-
|
|
42
|
-
options: TOptions,
|
|
43
|
-
): boolean => ResCache.Default.remove<TOptions, TData>(handler, options);
|
|
38
|
+
export const removeFromCache = (id: string): boolean =>
|
|
39
|
+
SsrCache.Default.remove(id);
|
|
44
40
|
|
|
45
|
-
export const removeAllFromCache =
|
|
46
|
-
handler: IRequestHandler<TOptions, TData>,
|
|
41
|
+
export const removeAllFromCache = (
|
|
47
42
|
predicate?: (
|
|
48
43
|
key: string,
|
|
49
|
-
cacheEntry: ?$ReadOnly<
|
|
44
|
+
cacheEntry: ?$ReadOnly<CachedResponse<ValidCacheData>>,
|
|
50
45
|
) => boolean,
|
|
51
|
-
):
|
|
46
|
+
): void => SsrCache.Default.removeAll(predicate);
|
|
52
47
|
|
|
53
|
-
export {default as RequestHandler} from "./util/request-handler.js";
|
|
54
48
|
export {default as TrackData} from "./components/track-data.js";
|
|
55
49
|
export {default as Data} from "./components/data.js";
|
|
56
|
-
export {default as
|
|
57
|
-
export {
|
|
50
|
+
export {default as InterceptRequests} from "./components/intercept-requests.js";
|
|
51
|
+
export {useServerEffect} from "./hooks/use-server-effect.js";
|
|
52
|
+
export {useRequestInterception} from "./hooks/use-request-interception.js";
|
|
53
|
+
export {useSharedCache, clearSharedCache} from "./hooks/use-shared-cache.js";
|
|
54
|
+
export {ScopedInMemoryCache} from "./util/scoped-in-memory-cache.js";
|
|
58
55
|
|
|
59
56
|
// GraphQL
|
|
60
57
|
export {GqlRouter} from "./components/gql-router.js";
|