@khanacademy/wonder-blocks-data 2.3.4 → 3.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 (39) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/es/index.js +212 -446
  3. package/dist/index.js +230 -478
  4. package/docs.md +19 -13
  5. package/package.json +2 -3
  6. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +40 -160
  7. package/src/__tests__/generated-snapshot.test.js +15 -195
  8. package/src/components/__tests__/data.test.js +159 -965
  9. package/src/components/__tests__/intercept-data.test.js +9 -66
  10. package/src/components/__tests__/track-data.test.js +6 -5
  11. package/src/components/data.js +9 -117
  12. package/src/components/data.md +38 -60
  13. package/src/components/intercept-data.js +2 -34
  14. package/src/components/intercept-data.md +7 -105
  15. package/src/hooks/__tests__/use-data.test.js +790 -0
  16. package/src/hooks/use-data.js +138 -0
  17. package/src/index.js +1 -3
  18. package/src/util/__tests__/memory-cache.test.js +134 -35
  19. package/src/util/__tests__/request-fulfillment.test.js +21 -36
  20. package/src/util/__tests__/request-handler.test.js +30 -30
  21. package/src/util/__tests__/request-tracking.test.js +29 -30
  22. package/src/util/__tests__/response-cache.test.js +521 -561
  23. package/src/util/__tests__/result-from-cache-entry.test.js +68 -0
  24. package/src/util/memory-cache.js +18 -14
  25. package/src/util/request-fulfillment.js +4 -0
  26. package/src/util/request-handler.js +2 -27
  27. package/src/util/request-handler.md +0 -32
  28. package/src/util/response-cache.js +50 -110
  29. package/src/util/result-from-cache-entry.js +38 -0
  30. package/src/util/types.js +14 -35
  31. package/LICENSE +0 -21
  32. package/src/components/__tests__/intercept-cache.test.js +0 -124
  33. package/src/components/__tests__/internal-data.test.js +0 -1030
  34. package/src/components/intercept-cache.js +0 -79
  35. package/src/components/intercept-cache.md +0 -103
  36. package/src/components/internal-data.js +0 -219
  37. package/src/util/__tests__/no-cache.test.js +0 -112
  38. package/src/util/no-cache.js +0 -67
  39. package/src/util/no-cache.md +0 -66
@@ -0,0 +1,138 @@
1
+ // @flow
2
+ import {Server} from "@khanacademy/wonder-blocks-core";
3
+ import {useState, useEffect, useContext, useRef} from "react";
4
+ import {RequestFulfillment} from "../util/request-fulfillment.js";
5
+ import InterceptContext from "../components/intercept-context.js";
6
+ import {TrackerContext} from "../util/request-tracking.js";
7
+ import {resultFromCacheEntry} from "../util/result-from-cache-entry.js";
8
+ import {ResponseCache} from "../util/response-cache.js";
9
+
10
+ import type {
11
+ Result,
12
+ IRequestHandler,
13
+ ValidData,
14
+ CacheEntry,
15
+ } from "../util/types.js";
16
+
17
+ export const useData = <TOptions, TData: ValidData>(
18
+ handler: IRequestHandler<TOptions, TData>,
19
+ options: TOptions,
20
+ ): Result<TData> => {
21
+ // If we're server-side or hydrating, we'll have a cached entry to use.
22
+ // So we get that and use it to initialize our state.
23
+ // This works in both hydration and SSR because the very first call to
24
+ // this will have cached data in those cases as it will be present on the
25
+ // initial render - and subsequent renders on the client it will be null.
26
+ const cachedResult = ResponseCache.Default.getEntry<TOptions, TData>(
27
+ handler,
28
+ options,
29
+ );
30
+ const [result, setResult] = useState<?CacheEntry<TData>>(cachedResult);
31
+
32
+ // We only track data requests when we are server-side and we don't
33
+ // already have a result, as given by the cachedData (which is also the
34
+ // initial value for the result state).
35
+ const maybeTrack = useContext(TrackerContext);
36
+ if (result == null && Server.isServerSide()) {
37
+ maybeTrack?.(handler, options);
38
+ }
39
+
40
+ // Lookup to see if there's an interceptor for the handler.
41
+ // If we have one, we need to replace the handler with one that
42
+ // uses the interceptor.
43
+ const interceptorMap = useContext(InterceptContext);
44
+ const interceptor = interceptorMap[handler.type];
45
+
46
+ // We need to update our request when the handler changes or the key
47
+ // to the options change, so we keep track of those.
48
+ // However, even if we are hydrating from cache, we still need to make the
49
+ // request at least once, so we do not initialize these references.
50
+ const handlerRef = useRef();
51
+ const keyRef = useRef();
52
+ const interceptorRef = useRef();
53
+
54
+ // This effect will ensure that we fulfill the request as desired.
55
+ useEffect(() => {
56
+ // If we are server-side, then just skip the effect. We track requests
57
+ // during SSR and fulfill them outside of the React render cycle.
58
+ // NOTE: This shouldn't happen since effects would not run on the server
59
+ // but let's be defensive - I think it makes the code clearer.
60
+ /* istanbul ignore next */
61
+ if (Server.isServerSide()) {
62
+ return;
63
+ }
64
+
65
+ // Update our refs to the current handler and key.
66
+ handlerRef.current = handler;
67
+ keyRef.current = handler.getKey(options);
68
+ interceptorRef.current = interceptor;
69
+
70
+ // If we're not hydrating a result, we want to make sure we set our
71
+ // result to null so that we're in the loading state.
72
+ if (cachedResult == null) {
73
+ // Mark ourselves as loading.
74
+ setResult(null);
75
+ }
76
+
77
+ const getMaybeInterceptedHandler = () => {
78
+ if (interceptor == null) {
79
+ return handler;
80
+ }
81
+
82
+ const fulfillRequestFn = (options) =>
83
+ interceptor.fulfillRequest(options) ??
84
+ handler.fulfillRequest(options);
85
+ return {
86
+ fulfillRequest: fulfillRequestFn,
87
+ getKey: (options) => handler.getKey(options),
88
+ type: handler.type,
89
+ hydrate: handler.hydrate,
90
+ };
91
+ };
92
+
93
+ // We aren't server-side, so let's make the request.
94
+ // The request handler is in control of whether that request actually
95
+ // happens or not.
96
+ let cancel = false;
97
+ RequestFulfillment.Default.fulfill(
98
+ getMaybeInterceptedHandler(),
99
+ options,
100
+ )
101
+ .then((updateEntry) => {
102
+ if (cancel) {
103
+ return;
104
+ }
105
+ setResult(updateEntry);
106
+ return;
107
+ })
108
+ .catch((e) => {
109
+ if (cancel) {
110
+ return;
111
+ }
112
+ /**
113
+ * We should never get here as errors in fulfillment are part
114
+ * of the `then`, but if we do.
115
+ */
116
+ // eslint-disable-next-line no-console
117
+ console.error(
118
+ `Unexpected error occurred during data fulfillment: ${e}`,
119
+ );
120
+ setResult({
121
+ data: null,
122
+ error: typeof e === "string" ? e : e.message,
123
+ });
124
+ return;
125
+ });
126
+
127
+ return () => {
128
+ cancel = true;
129
+ };
130
+ // - handler.getKey is a proxy for options
131
+ // - We don't want to trigger on cachedResult changing, we're
132
+ // just using that as a flag for render state if the other things
133
+ // trigger this effect.
134
+ // eslint-disable-next-line react-hooks/exhaustive-deps
135
+ }, [handler, handler.getKey(options), interceptor]);
136
+
137
+ return resultFromCacheEntry(result);
138
+ };
package/src/index.js CHANGED
@@ -15,7 +15,6 @@ export type {
15
15
  CacheEntry,
16
16
  Result,
17
17
  IRequestHandler,
18
- ICache,
19
18
  ResponseCache,
20
19
  } from "./util/types.js";
21
20
 
@@ -61,5 +60,4 @@ export {default as RequestHandler} from "./util/request-handler.js";
61
60
  export {default as TrackData} from "./components/track-data.js";
62
61
  export {default as Data} from "./components/data.js";
63
62
  export {default as InterceptData} from "./components/intercept-data.js";
64
- export {default as InterceptCache} from "./components/intercept-cache.js";
65
- export {default as NoCache} from "./util/no-cache.js";
63
+ export {useData} from "./hooks/use-data.js";
@@ -47,9 +47,7 @@ describe("MemoryCache", () => {
47
47
  const fakeHandler: IRequestHandler<string, string> = {
48
48
  getKey: () => "MY_KEY",
49
49
  type: "MY_HANDLER",
50
- shouldRefreshCache: () => false,
51
50
  fulfillRequest: jest.fn(),
52
- cache: null,
53
51
  hydrate: true,
54
52
  };
55
53
 
@@ -71,9 +69,7 @@ describe("MemoryCache", () => {
71
69
  const fakeHandler: IRequestHandler<string, string> = {
72
70
  getKey: () => "MY_KEY",
73
71
  type: "MY_HANDLER",
74
- shouldRefreshCache: () => false,
75
72
  fulfillRequest: jest.fn(),
76
- cache: null,
77
73
  hydrate: true,
78
74
  };
79
75
 
@@ -95,9 +91,7 @@ describe("MemoryCache", () => {
95
91
  const fakeHandler: IRequestHandler<string, string> = {
96
92
  getKey: () => "MY_KEY",
97
93
  type: "MY_HANDLER",
98
- shouldRefreshCache: () => false,
99
94
  fulfillRequest: jest.fn(),
100
- cache: null,
101
95
  hydrate: true,
102
96
  };
103
97
 
@@ -119,9 +113,7 @@ describe("MemoryCache", () => {
119
113
  const fakeHandler: IRequestHandler<string, string> = {
120
114
  getKey: () => "MY_KEY",
121
115
  type: "MY_HANDLER",
122
- shouldRefreshCache: () => false,
123
116
  fulfillRequest: jest.fn(),
124
- cache: null,
125
117
  hydrate: true,
126
118
  };
127
119
 
@@ -142,9 +134,7 @@ describe("MemoryCache", () => {
142
134
  const fakeHandler: IRequestHandler<string, string> = {
143
135
  getKey: () => "MY_KEY",
144
136
  type: "MY_HANDLER",
145
- shouldRefreshCache: () => false,
146
137
  fulfillRequest: jest.fn(),
147
- cache: null,
148
138
  hydrate: true,
149
139
  };
150
140
 
@@ -165,9 +155,7 @@ describe("MemoryCache", () => {
165
155
  const fakeHandler: IRequestHandler<string, string> = {
166
156
  getKey: () => "MY_KEY",
167
157
  type: "MY_HANDLER",
168
- shouldRefreshCache: () => false,
169
158
  fulfillRequest: jest.fn(),
170
- cache: null,
171
159
  hydrate: true,
172
160
  };
173
161
 
@@ -186,9 +174,7 @@ describe("MemoryCache", () => {
186
174
  const fakeHandler: IRequestHandler<string, string> = {
187
175
  getKey: () => "MY_KEY",
188
176
  type: "MY_HANDLER",
189
- shouldRefreshCache: () => false,
190
177
  fulfillRequest: jest.fn(),
191
- cache: null,
192
178
  hydrate: true,
193
179
  };
194
180
 
@@ -207,9 +193,7 @@ describe("MemoryCache", () => {
207
193
  const fakeHandler: IRequestHandler<string, string> = {
208
194
  getKey: () => "MY_KEY",
209
195
  type: "MY_HANDLER",
210
- shouldRefreshCache: () => false,
211
196
  fulfillRequest: jest.fn(),
212
- cache: null,
213
197
  hydrate: true,
214
198
  };
215
199
 
@@ -230,9 +214,7 @@ describe("MemoryCache", () => {
230
214
  const fakeHandler: IRequestHandler<string, string> = {
231
215
  getKey: () => "MY_KEY",
232
216
  type: "MY_HANDLER",
233
- shouldRefreshCache: () => false,
234
217
  fulfillRequest: jest.fn(),
235
- cache: null,
236
218
  hydrate: true,
237
219
  };
238
220
 
@@ -253,9 +235,7 @@ describe("MemoryCache", () => {
253
235
  const fakeHandler: IRequestHandler<string, string> = {
254
236
  getKey: () => "MY_KEY",
255
237
  type: "MY_HANDLER",
256
- shouldRefreshCache: () => false,
257
238
  fulfillRequest: jest.fn(),
258
- cache: null,
259
239
  hydrate: true,
260
240
  };
261
241
 
@@ -269,6 +249,23 @@ describe("MemoryCache", () => {
269
249
  });
270
250
 
271
251
  describe("#removeAll", () => {
252
+ it("should return 0 if the handler subcache is absent", () => {
253
+ // Arrange
254
+ const cache = new MemoryCache();
255
+ const fakeHandler: IRequestHandler<string, string> = {
256
+ getKey: () => "MY_KEY",
257
+ type: "MY_HANDLER",
258
+ fulfillRequest: jest.fn(),
259
+ hydrate: true,
260
+ };
261
+
262
+ // Act
263
+ const result = cache.removeAll(fakeHandler);
264
+
265
+ // Assert
266
+ expect(result).toBe(0);
267
+ });
268
+
272
269
  it("should remove matching entries from handler subcache", () => {
273
270
  const cache = new MemoryCache({
274
271
  MY_HANDLER: {
@@ -283,32 +280,84 @@ describe("MemoryCache", () => {
283
280
  const fakeHandler: IRequestHandler<string, string> = {
284
281
  getKey: () => "MY_KEY",
285
282
  type: "MY_HANDLER",
286
- shouldRefreshCache: () => false,
287
283
  fulfillRequest: jest.fn(),
288
- cache: null,
284
+ hydrate: true,
285
+ };
286
+
287
+ // Act
288
+ cache.removeAll(fakeHandler, (k, d) => d.data === "2");
289
+ const result = cache.cloneData();
290
+
291
+ // Assert
292
+ expect(result).toStrictEqual({
293
+ MY_HANDLER: {
294
+ MY_KEY2: {data: "1"},
295
+ },
296
+ OTHER_HANDLER: {
297
+ MY_KEY: {data: "1"},
298
+ },
299
+ });
300
+ });
301
+
302
+ it("should return the number of items that matched the predicate and were removed", () => {
303
+ const cache = new MemoryCache({
304
+ MY_HANDLER: {
305
+ MY_KEY: {data: "a"},
306
+ MY_KEY2: {data: "b"},
307
+ MY_KEY3: {data: "a"},
308
+ },
309
+ OTHER_HANDLER: {
310
+ MY_KEY: {data: "b"},
311
+ },
312
+ });
313
+ const fakeHandler: IRequestHandler<string, string> = {
314
+ getKey: () => "MY_KEY",
315
+ type: "MY_HANDLER",
316
+ fulfillRequest: jest.fn(),
289
317
  hydrate: true,
290
318
  };
291
319
 
292
320
  // Act
293
321
  const result = cache.removeAll(
294
322
  fakeHandler,
295
- (k, d) => d.data === "2",
323
+ (k, d) => d.data === "a",
296
324
  );
297
- const after = cache.cloneData();
298
325
 
299
326
  // Assert
300
327
  expect(result).toBe(2);
301
- expect(after).toStrictEqual({
328
+ });
329
+
330
+ it("should remove the entire handler subcache if no predicate", () => {
331
+ const cache = new MemoryCache({
302
332
  MY_HANDLER: {
303
- MY_KEY2: {data: "1"},
333
+ MY_KEY: {data: "data!"},
334
+ MY_KEY2: {data: "data!"},
335
+ MY_KEY3: {data: "data!"},
304
336
  },
305
337
  OTHER_HANDLER: {
306
- MY_KEY: {data: "1"},
338
+ MY_KEY: {data: "data!"},
339
+ },
340
+ });
341
+ const fakeHandler: IRequestHandler<string, string> = {
342
+ getKey: () => "MY_KEY",
343
+ type: "MY_HANDLER",
344
+ fulfillRequest: jest.fn(),
345
+ hydrate: true,
346
+ };
347
+
348
+ // Act
349
+ cache.removeAll(fakeHandler);
350
+ const result = cache.cloneData();
351
+
352
+ // Assert
353
+ expect(result).toStrictEqual({
354
+ OTHER_HANDLER: {
355
+ MY_KEY: {data: "data!"},
307
356
  },
308
357
  });
309
358
  });
310
359
 
311
- it("should remove all entries from handler subcache if no predicate", () => {
360
+ it("should return the number of items that were in the handler subcache if there was no predicate", () => {
312
361
  const cache = new MemoryCache({
313
362
  MY_HANDLER: {
314
363
  MY_KEY: {data: "data!"},
@@ -322,26 +371,76 @@ describe("MemoryCache", () => {
322
371
  const fakeHandler: IRequestHandler<string, string> = {
323
372
  getKey: () => "MY_KEY",
324
373
  type: "MY_HANDLER",
325
- shouldRefreshCache: () => false,
326
374
  fulfillRequest: jest.fn(),
327
- cache: null,
328
375
  hydrate: true,
329
376
  };
330
377
 
331
378
  // Act
332
379
  const result = cache.removeAll(fakeHandler);
333
- const after = cache.cloneData();
334
380
 
335
381
  // Assert
336
382
  expect(result).toBe(3);
337
- expect(after).toStrictEqual({
338
- MY_HANDLER: {},
339
- OTHER_HANDLER: {
383
+ });
384
+ });
385
+
386
+ describe("#cloneData", () => {
387
+ it("should return a copy of the cache data", () => {});
388
+
389
+ it("should throw if there is an error during cloning", () => {
390
+ // Arrange
391
+ const cache = new MemoryCache({
392
+ MY_HANDLER: {
340
393
  MY_KEY: {data: "data!"},
341
394
  },
342
395
  });
396
+ jest.spyOn(JSON, "stringify").mockImplementation(() => {
397
+ throw new Error("BANG!");
398
+ });
399
+
400
+ // Act
401
+ const act = () => cache.cloneData();
402
+
403
+ // Assert
404
+ expect(act).toThrowErrorMatchingInlineSnapshot(
405
+ `"An error occurred while trying to clone the cache: Error: BANG!"`,
406
+ );
343
407
  });
344
408
  });
345
409
 
346
- describe("#cloneData", () => {});
410
+ describe("@inUse", () => {
411
+ it("should return true if the cache contains data", () => {
412
+ // Arrange
413
+ const cache = new MemoryCache({
414
+ MY_HANDLER: {
415
+ MY_KEY: {data: "data!"},
416
+ },
417
+ });
418
+
419
+ // Act
420
+ const result = cache.inUse;
421
+
422
+ // Assert
423
+ expect(result).toBeTruthy();
424
+ });
425
+
426
+ it("should return false if the cache is empty", () => {
427
+ // Arrange
428
+ const cache = new MemoryCache({
429
+ MY_HANDLER: {
430
+ MY_KEY: {data: "data!"},
431
+ },
432
+ });
433
+ cache.removeAll(
434
+ ({
435
+ type: "MY_HANDLER",
436
+ }: any),
437
+ );
438
+
439
+ // Act
440
+ const result = cache.inUse;
441
+
442
+ // Assert
443
+ expect(result).toBeFalsy();
444
+ });
445
+ });
347
446
  });
@@ -17,32 +17,30 @@ describe("RequestFulfillment", () => {
17
17
  });
18
18
 
19
19
  describe("#fulfill", () => {
20
- it("should cache errors caused directly by handlers", async () => {
20
+ it("should attempt to cache errors caused directly by handlers", async () => {
21
21
  // Arrange
22
22
  const responseCache = new ResponseCache();
23
23
  const requestFulfillment = new RequestFulfillment(responseCache);
24
+ const error = new Error("OH NO!");
24
25
  const fakeBadHandler: IRequestHandler<any, any> = {
25
26
  fulfillRequest: () => {
26
- throw new Error("OH NO!");
27
+ throw error;
27
28
  },
28
29
  getKey: jest.fn().mockReturnValue("MY_KEY"),
29
- shouldRefreshCache: () => false,
30
30
  type: "MY_TYPE",
31
- cache: null,
32
31
  hydrate: true,
33
32
  };
33
+ const cacheErrorSpy = jest.spyOn(responseCache, "cacheError");
34
34
 
35
35
  // Act
36
36
  await requestFulfillment.fulfill(fakeBadHandler, "OPTIONS");
37
37
 
38
38
  // Assert
39
- expect(responseCache.cloneHydratableData()).toStrictEqual({
40
- MY_TYPE: {
41
- MY_KEY: {
42
- error: "OH NO!",
43
- },
44
- },
45
- });
39
+ expect(cacheErrorSpy).toHaveBeenCalledWith(
40
+ fakeBadHandler,
41
+ "OPTIONS",
42
+ error,
43
+ );
46
44
  });
47
45
 
48
46
  it("should cache errors occurring in promises", async () => {
@@ -53,23 +51,20 @@ describe("RequestFulfillment", () => {
53
51
  fulfillRequest: () =>
54
52
  new Promise((resolve, reject) => reject("OH NO!")),
55
53
  getKey: (o) => o,
56
- shouldRefreshCache: () => false,
57
54
  type: "BAD_REQUEST",
58
- cache: null,
59
55
  hydrate: true,
60
56
  };
57
+ const cacheErrorSpy = jest.spyOn(responseCache, "cacheError");
61
58
 
62
59
  // Act
63
60
  await requestFulfillment.fulfill(fakeBadRequestHandler, "OPTIONS");
64
61
 
65
62
  // Assert
66
- expect(responseCache.cloneHydratableData()).toStrictEqual({
67
- BAD_REQUEST: {
68
- OPTIONS: {
69
- error: "OH NO!",
70
- },
71
- },
72
- });
63
+ expect(cacheErrorSpy).toHaveBeenCalledWith(
64
+ fakeBadRequestHandler,
65
+ "OPTIONS",
66
+ "OH NO!",
67
+ );
73
68
  });
74
69
 
75
70
  it("should cache data from requests", async () => {
@@ -79,24 +74,20 @@ describe("RequestFulfillment", () => {
79
74
  const fakeRequestHandler: IRequestHandler<string, any> = {
80
75
  fulfillRequest: () => Promise.resolve("DATA!"),
81
76
  getKey: (o) => o,
82
- shouldRefreshCache: () => false,
83
77
  type: "VALID_REQUEST",
84
- cache: null,
85
78
  hydrate: true,
86
79
  };
80
+ const cacheDataSpy = jest.spyOn(responseCache, "cacheData");
87
81
 
88
82
  // Act
89
83
  await requestFulfillment.fulfill(fakeRequestHandler, "OPTIONS");
90
- const result = responseCache.cloneHydratableData();
91
84
 
92
85
  // Assert
93
- expect(result).toStrictEqual({
94
- VALID_REQUEST: {
95
- OPTIONS: {
96
- data: "DATA!",
97
- },
98
- },
99
- });
86
+ expect(cacheDataSpy).toHaveBeenCalledWith(
87
+ fakeRequestHandler,
88
+ "OPTIONS",
89
+ "DATA!",
90
+ );
100
91
  });
101
92
 
102
93
  it("should return a promise of the result", async () => {
@@ -106,9 +97,7 @@ describe("RequestFulfillment", () => {
106
97
  const fakeRequestHandler: IRequestHandler<string, any> = {
107
98
  fulfillRequest: () => Promise.resolve("DATA!"),
108
99
  getKey: (o) => o,
109
- shouldRefreshCache: () => false,
110
100
  type: "VALID_REQUEST",
111
- cache: null,
112
101
  hydrate: true,
113
102
  };
114
103
 
@@ -131,9 +120,7 @@ describe("RequestFulfillment", () => {
131
120
  const fakeRequestHandler: IRequestHandler<string, any> = {
132
121
  fulfillRequest: () => Promise.resolve("DATA!"),
133
122
  getKey: (o) => o,
134
- shouldRefreshCache: () => false,
135
123
  type: "VALID_REQUEST",
136
- cache: null,
137
124
  hydrate: true,
138
125
  };
139
126
 
@@ -158,9 +145,7 @@ describe("RequestFulfillment", () => {
158
145
  const fakeRequestHandler: IRequestHandler<string, any> = {
159
146
  fulfillRequest: () => Promise.resolve("DATA!"),
160
147
  getKey: (o) => o,
161
- shouldRefreshCache: () => false,
162
148
  type: "VALID_REQUEST",
163
- cache: null,
164
149
  hydrate: false,
165
150
  };
166
151