@khanacademy/wonder-blocks-data 3.2.0 → 4.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 (42) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/es/index.js +356 -332
  3. package/dist/index.js +507 -456
  4. package/docs.md +17 -35
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +7 -46
  7. package/src/__tests__/generated-snapshot.test.js +56 -122
  8. package/src/components/__tests__/data.test.js +372 -297
  9. package/src/components/__tests__/intercept-data.test.js +6 -30
  10. package/src/components/data.js +153 -21
  11. package/src/components/data.md +38 -69
  12. package/src/components/intercept-context.js +6 -2
  13. package/src/components/intercept-data.js +40 -51
  14. package/src/components/intercept-data.md +13 -27
  15. package/src/components/track-data.md +9 -23
  16. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +17 -0
  17. package/src/hooks/__tests__/use-server-effect.test.js +217 -0
  18. package/src/hooks/__tests__/use-shared-cache.test.js +307 -0
  19. package/src/hooks/use-server-effect.js +45 -0
  20. package/src/hooks/use-shared-cache.js +106 -0
  21. package/src/index.js +15 -19
  22. package/src/util/__tests__/__snapshots__/scoped-in-memory-cache.test.js.snap +19 -0
  23. package/src/util/__tests__/request-fulfillment.test.js +42 -85
  24. package/src/util/__tests__/request-tracking.test.js +72 -191
  25. package/src/util/__tests__/{result-from-cache-entry.test.js → result-from-cache-response.test.js} +9 -10
  26. package/src/util/__tests__/scoped-in-memory-cache.test.js +396 -0
  27. package/src/util/__tests__/ssr-cache.test.js +639 -0
  28. package/src/util/request-fulfillment.js +36 -44
  29. package/src/util/request-tracking.js +62 -75
  30. package/src/util/{result-from-cache-entry.js → result-from-cache-response.js} +10 -13
  31. package/src/util/scoped-in-memory-cache.js +149 -0
  32. package/src/util/ssr-cache.js +206 -0
  33. package/src/util/types.js +43 -108
  34. package/src/hooks/__tests__/use-data.test.js +0 -826
  35. package/src/hooks/use-data.js +0 -143
  36. package/src/util/__tests__/memory-cache.test.js +0 -446
  37. package/src/util/__tests__/request-handler.test.js +0 -121
  38. package/src/util/__tests__/response-cache.test.js +0 -879
  39. package/src/util/memory-cache.js +0 -187
  40. package/src/util/request-handler.js +0 -42
  41. package/src/util/request-handler.md +0 -51
  42. package/src/util/response-cache.js +0 -213
package/dist/es/index.js CHANGED
@@ -1,141 +1,101 @@
1
1
  import { Server } from '@khanacademy/wonder-blocks-core';
2
+ import { KindError, Errors, clone } from '@khanacademy/wonder-stuff-core';
2
3
  import * as React from 'react';
3
- import { useState, useContext, useRef, useEffect, useMemo } from 'react';
4
+ import { useContext, useMemo } from 'react';
4
5
  import _extends from '@babel/runtime/helpers/extends';
5
- import { Errors, KindError } from '@khanacademy/wonder-stuff-core';
6
6
 
7
- function deepClone(source) {
8
- /**
9
- * We want to deep clone the source cache to dodge mutations by external
10
- * references. So we serialize the source cache to JSON and parse it
11
- * back into a new object.
12
- *
13
- * NOTE: This doesn't work for get/set property accessors.
14
- */
15
- const serializedInitCache = JSON.stringify(source);
16
- const cloneInitCache = JSON.parse(serializedInitCache);
17
- return Object.freeze(cloneInitCache);
18
- }
19
7
  /**
20
- * INTERNAL USE ONLY
21
- *
22
- * Special case cache implementation for the memory cache.
23
- *
24
- * This is only used within our framework for SSR (see ./response-cache.js).
8
+ * Describe an in-memory cache.
25
9
  */
10
+ class ScopedInMemoryCache {
11
+ constructor(initialCache = Object.freeze({})) {
12
+ this.set = (scope, id, value) => {
13
+ var _this$_cache$scope;
26
14
 
15
+ if (!id || typeof id !== "string") {
16
+ throw new KindError("id must be non-empty string", Errors.InvalidInput);
17
+ }
27
18
 
28
- class MemoryCache {
29
- constructor(source = null) {
30
- this.store = (handler, options, entry) => {
31
- const requestType = handler.type;
32
- const frozenEntry = Object.freeze(entry); // Ensure we have a cache location for this handler type.
19
+ if (!scope || typeof scope !== "string") {
20
+ throw new KindError("scope must be non-empty string", Errors.InvalidInput);
21
+ }
33
22
 
34
- this._cache[requestType] = this._cache[requestType] || {}; // Cache the data.
23
+ if (typeof value === "function") {
24
+ throw new KindError("value must be a non-function value", Errors.InvalidInput);
25
+ }
35
26
 
36
- const key = handler.getKey(options);
37
- this._cache[requestType][key] = frozenEntry;
27
+ this._cache[scope] = (_this$_cache$scope = this._cache[scope]) != null ? _this$_cache$scope : {};
28
+ this._cache[scope][id] = Object.freeze(clone(value));
38
29
  };
39
30
 
40
- this.retrieve = (handler, options) => {
41
- const requestType = handler.type; // Get the internal subcache for the handler.
31
+ this.get = (scope, id) => {
32
+ var _this$_cache$scope$id, _this$_cache$scope2;
42
33
 
43
- const handlerCache = this._cache[requestType];
34
+ return (_this$_cache$scope$id = (_this$_cache$scope2 = this._cache[scope]) == null ? void 0 : _this$_cache$scope2[id]) != null ? _this$_cache$scope$id : null;
35
+ };
44
36
 
45
- if (!handlerCache) {
46
- return null;
47
- } // Get the response.
37
+ this.purge = (scope, id) => {
38
+ var _this$_cache$scope3;
48
39
 
40
+ if (!((_this$_cache$scope3 = this._cache[scope]) != null && _this$_cache$scope3[id])) {
41
+ return;
42
+ }
49
43
 
50
- const key = handler.getKey(options);
51
- const internalEntry = handlerCache[key];
44
+ delete this._cache[scope][id];
52
45
 
53
- if (internalEntry == null) {
54
- return null;
46
+ if (Object.keys(this._cache[scope]).length === 0) {
47
+ delete this._cache[scope];
55
48
  }
56
-
57
- return internalEntry;
58
49
  };
59
50
 
60
- this.remove = (handler, options) => {
61
- const requestType = handler.type; // NOTE(somewhatabstract): We could invoke removeAll with a predicate
62
- // to match the key of the entry we're removing, but that's an
63
- // inefficient way to remove a single item, so let's not do that.
64
- // Get the internal subcache for the handler.
65
-
66
- const handlerCache = this._cache[requestType];
67
-
68
- if (!handlerCache) {
69
- return false;
70
- } // Get the entry.
71
-
72
-
73
- const key = handler.getKey(options);
74
- const internalEntry = handlerCache[key];
51
+ this.purgeScope = (scope, predicate) => {
52
+ if (!this._cache[scope]) {
53
+ return;
54
+ }
75
55
 
76
- if (internalEntry == null) {
77
- return false;
78
- } // Delete the entry.
56
+ if (predicate == null) {
57
+ delete this._cache[scope];
58
+ return;
59
+ }
79
60
 
61
+ for (const key of Object.keys(this._cache[scope])) {
62
+ if (predicate(key, this._cache[scope][key])) {
63
+ delete this._cache[scope][key];
64
+ }
65
+ }
80
66
 
81
- delete handlerCache[key];
82
- return true;
67
+ if (Object.keys(this._cache[scope]).length === 0) {
68
+ delete this._cache[scope];
69
+ }
83
70
  };
84
71
 
85
- this.removeAll = (handler, predicate) => {
86
- const requestType = handler.type; // Get the internal subcache for the handler.
87
-
88
- const handlerCache = this._cache[requestType];
89
-
90
- if (!handlerCache) {
91
- return 0;
72
+ this.purgeAll = predicate => {
73
+ if (predicate == null) {
74
+ this._cache = {};
75
+ return;
92
76
  }
93
77
 
94
- let removedCount = 0;
95
-
96
- if (typeof predicate === "function") {
97
- // Apply the predicate to what we have cached.
98
- for (const [key, entry] of Object.entries(handlerCache)) {
99
- if (predicate(key, entry)) {
100
- removedCount++;
101
- delete handlerCache[key];
102
- }
103
- }
104
- } else {
105
- // We're removing everything so delete the entire subcache.
106
- removedCount = Object.keys(handlerCache).length;
107
- delete this._cache[requestType];
78
+ for (const scope of Object.keys(this._cache)) {
79
+ this.purgeScope(scope, (id, value) => predicate(scope, id, value));
108
80
  }
109
-
110
- return removedCount;
111
81
  };
112
82
 
113
- this.cloneData = () => {
83
+ this.clone = () => {
114
84
  try {
115
- return deepClone(this._cache);
85
+ return clone(this._cache);
116
86
  } catch (e) {
117
87
  throw new Error(`An error occurred while trying to clone the cache: ${e}`);
118
88
  }
119
89
  };
120
90
 
121
- this._cache = {};
122
-
123
- if (source != null) {
124
- try {
125
- /**
126
- * Object.assign only performs a shallow clone.
127
- * So we deep clone it and then assign the clone values to our
128
- * internal cache.
129
- */
130
- const cloneInitCache = deepClone(source);
131
- Object.assign(this._cache, cloneInitCache);
132
- } catch (e) {
133
- throw new Error(`An error occurred trying to initialize from a response cache snapshot: ${e}`);
134
- }
91
+ try {
92
+ this._cache = clone(initialCache);
93
+ } catch (e) {
94
+ throw new KindError(`An error occurred trying to initialize from a response cache snapshot: ${e}`, Errors.InvalidInput);
135
95
  }
136
96
  }
137
97
  /**
138
- * Indicate if this cache is being used or now.
98
+ * Indicate if this cache is being used or not.
139
99
  *
140
100
  * When the cache has entries, returns `true`; otherwise, returns `false`.
141
101
  */
@@ -144,13 +104,19 @@ class MemoryCache {
144
104
  get inUse() {
145
105
  return Object.keys(this._cache).length > 0;
146
106
  }
107
+ /**
108
+ * Set a value in the cache.
109
+ */
110
+
147
111
 
148
112
  }
149
113
 
114
+ const DefaultScope = "default";
150
115
  /**
151
116
  * The default instance is stored here.
152
117
  * It's created below in the Default() static property.
153
118
  */
119
+
154
120
  let _default$2;
155
121
  /**
156
122
  * Implements the response cache.
@@ -159,10 +125,10 @@ let _default$2;
159
125
  */
160
126
 
161
127
 
162
- class ResponseCache {
128
+ class SsrCache {
163
129
  static get Default() {
164
130
  if (!_default$2) {
165
- _default$2 = new ResponseCache();
131
+ _default$2 = new SsrCache();
166
132
  }
167
133
 
168
134
  return _default$2;
@@ -174,31 +140,29 @@ class ResponseCache {
174
140
  throw new Error("Cannot initialize data response cache more than once");
175
141
  }
176
142
 
177
- try {
178
- this._hydrationCache = new MemoryCache(source);
179
- } catch (e) {
180
- throw new Error(`An error occurred trying to initialize the data response cache: ${e}`);
181
- }
143
+ this._hydrationCache = new ScopedInMemoryCache({
144
+ // $FlowIgnore[incompatible-call]
145
+ [DefaultScope]: source
146
+ });
182
147
  };
183
148
 
184
- this.cacheData = (handler, options, data) => this._setCacheEntry(handler, options, {
149
+ this.cacheData = (id, data, hydrate) => this._setCachedResponse(id, {
185
150
  data
186
- });
151
+ }, hydrate);
187
152
 
188
- this.cacheError = (handler, options, error) => {
153
+ this.cacheError = (id, error, hydrate) => {
189
154
  const errorMessage = typeof error === "string" ? error : error.message;
190
- return this._setCacheEntry(handler, options, {
155
+ return this._setCachedResponse(id, {
191
156
  error: errorMessage
192
- });
157
+ }, hydrate);
193
158
  };
194
159
 
195
- this.getEntry = (handler, options) => {
160
+ this.getEntry = id => {
161
+ var _this$_ssrOnlyCache$g, _this$_ssrOnlyCache;
162
+
196
163
  // Get the cached entry for this value.
197
- // If the handler wants WB Data to hydrate (i.e. handler.hydrate is
198
- // true), we use our hydration cache. Otherwise, if we're server-side
199
- // we use our SSR-only cache. Otherwise, there's no entry to return.
200
- const cache = handler.hydrate ? this._hydrationCache : Server.isServerSide() ? this._ssrOnlyCache : undefined;
201
- const internalEntry = cache == null ? void 0 : cache.retrieve(handler, options); // If we are not server-side and we hydrated something, let's clear
164
+ // We first look in the ssr cache and then the hydration cache.
165
+ const internalEntry = (_this$_ssrOnlyCache$g = (_this$_ssrOnlyCache = this._ssrOnlyCache) == null ? void 0 : _this$_ssrOnlyCache.get(DefaultScope, id)) != null ? _this$_ssrOnlyCache$g : this._hydrationCache.get(DefaultScope, id); // If we are not server-side and we hydrated something, let's clear
202
166
  // that from the hydration cache to save memory.
203
167
 
204
168
  if (this._ssrOnlyCache == null && internalEntry != null) {
@@ -208,48 +172,71 @@ class ResponseCache {
208
172
  // that's not an expected use-case. If two different places use the
209
173
  // same handler and options (i.e. the same request), then the
210
174
  // handler should cater to that to ensure they share the result.
211
- this._hydrationCache.remove(handler, options);
212
- }
175
+ this._hydrationCache.purge(DefaultScope, id);
176
+ } // Getting the typing right between the in-memory cache and this
177
+ // is hard. Just telling flow it's OK.
178
+ // $FlowIgnore[incompatible-return]
179
+
213
180
 
214
181
  return internalEntry;
215
182
  };
216
183
 
217
- this.remove = (handler, options) => {
218
- var _this$_ssrOnlyCache$r, _this$_ssrOnlyCache;
184
+ this.remove = id => {
185
+ var _this$_ssrOnlyCache$p, _this$_ssrOnlyCache2;
219
186
 
220
187
  // NOTE(somewhatabstract): We could invoke removeAll with a predicate
221
188
  // to match the key of the entry we're removing, but that's an
222
189
  // inefficient way to remove a single item, so let's not do that.
223
190
  // Delete the entry from the appropriate cache.
224
- return handler.hydrate ? this._hydrationCache.remove(handler, options) : (_this$_ssrOnlyCache$r = (_this$_ssrOnlyCache = this._ssrOnlyCache) == null ? void 0 : _this$_ssrOnlyCache.remove(handler, options)) != null ? _this$_ssrOnlyCache$r : false;
191
+ return this._hydrationCache.purge(DefaultScope, id) || ((_this$_ssrOnlyCache$p = (_this$_ssrOnlyCache2 = this._ssrOnlyCache) == null ? void 0 : _this$_ssrOnlyCache2.purge(DefaultScope, id)) != null ? _this$_ssrOnlyCache$p : false);
225
192
  };
226
193
 
227
- this.removeAll = (handler, predicate) => {
228
- var _this$_ssrOnlyCache$r2, _this$_ssrOnlyCache2;
194
+ this.removeAll = predicate => {
195
+ var _this$_ssrOnlyCache3;
196
+
197
+ const realPredicate = predicate ? // We know what we're putting into the cache so let's assume it
198
+ // conforms.
199
+ // $FlowIgnore[incompatible-call]
200
+ (_, key, cachedEntry) => predicate(key, cachedEntry) : undefined; // Apply the predicate to what we have in our caches.
229
201
 
230
- // Apply the predicate to what we have in the appropriate cache.
231
- return handler.hydrate ? this._hydrationCache.removeAll(handler, predicate) : (_this$_ssrOnlyCache$r2 = (_this$_ssrOnlyCache2 = this._ssrOnlyCache) == null ? void 0 : _this$_ssrOnlyCache2.removeAll(handler, predicate)) != null ? _this$_ssrOnlyCache$r2 : 0;
202
+ this._hydrationCache.purgeAll(realPredicate);
203
+
204
+ (_this$_ssrOnlyCache3 = this._ssrOnlyCache) == null ? void 0 : _this$_ssrOnlyCache3.purgeAll(realPredicate);
232
205
  };
233
206
 
234
207
  this.cloneHydratableData = () => {
208
+ var _cache$DefaultScope;
209
+
235
210
  // We return our hydration cache only.
236
- return this._hydrationCache.cloneData();
211
+ const cache = this._hydrationCache.clone(); // If we're empty, we still want to return an object, so we default
212
+ // to an empty object.
213
+ // We only need the default scope out of our scoped in-memory cache.
214
+ // We know that it conforms to our expectations.
215
+ // $FlowIgnore[incompatible-return]
216
+
217
+
218
+ return (_cache$DefaultScope = cache[DefaultScope]) != null ? _cache$DefaultScope : {};
237
219
  };
238
220
 
239
- this._ssrOnlyCache = Server.isServerSide() ? ssrOnlyCache || new MemoryCache() : undefined;
240
- this._hydrationCache = hydrationCache || new MemoryCache();
221
+ this._ssrOnlyCache = Server.isServerSide() ? ssrOnlyCache || new ScopedInMemoryCache() : undefined;
222
+ this._hydrationCache = hydrationCache || new ScopedInMemoryCache();
241
223
  }
242
224
 
243
- _setCacheEntry(handler, options, entry) {
225
+ _setCachedResponse(id, entry, hydrate) {
244
226
  const frozenEntry = Object.freeze(entry);
245
227
 
246
- if (this._ssrOnlyCache != null) {
228
+ if (Server.isServerSide()) {
247
229
  // We are server-side.
248
230
  // We need to store this value.
249
- if (handler.hydrate) {
250
- this._hydrationCache.store(handler, options, frozenEntry);
231
+ if (hydrate) {
232
+ this._hydrationCache.set(DefaultScope, id, frozenEntry);
251
233
  } else {
252
- this._ssrOnlyCache.store(handler, options, frozenEntry);
234
+ var _this$_ssrOnlyCache4;
235
+
236
+ // Usually, when server-side, this cache will always be present.
237
+ // We do fake server-side in our doc example though, when it
238
+ // won't be.
239
+ (_this$_ssrOnlyCache4 = this._ssrOnlyCache) == null ? void 0 : _this$_ssrOnlyCache4.set(DefaultScope, id, frozenEntry);
253
240
  }
254
241
  }
255
242
 
@@ -278,23 +265,14 @@ class RequestFulfillment {
278
265
  constructor(responseCache = undefined) {
279
266
  this._requests = {};
280
267
 
281
- this._getHandlerSubcache = handler => {
282
- if (!this._requests[handler.type]) {
283
- this._requests[handler.type] = {};
284
- }
285
-
286
- return this._requests[handler.type];
287
- };
288
-
289
- this.fulfill = (handler, options) => {
290
- const handlerRequests = this._getHandlerSubcache(handler);
291
-
292
- const key = handler.getKey(options);
268
+ this.fulfill = (id, {
269
+ handler,
270
+ hydrate: _hydrate = true
271
+ }) => {
293
272
  /**
294
273
  * If we have an inflight request, we'll provide that.
295
274
  */
296
-
297
- const inflight = handlerRequests[key];
275
+ const inflight = this._requests[id];
298
276
 
299
277
  if (inflight) {
300
278
  return inflight;
@@ -310,38 +288,51 @@ class RequestFulfillment {
310
288
  } = this._responseCache;
311
289
 
312
290
  try {
313
- const request = handler.fulfillRequest(options).then(data => {
314
- delete handlerRequests[key];
291
+ const request = handler().then(data => {
292
+ delete this._requests[id];
293
+
294
+ if (data == null) {
295
+ // Request aborted. We won't cache this.
296
+ return null;
297
+ }
315
298
  /**
316
299
  * Let's cache the data!
317
300
  *
318
301
  * NOTE: This only caches when we're server side.
319
302
  */
320
303
 
321
- return cacheData(handler, options, data);
304
+
305
+ return cacheData(id, data, _hydrate);
322
306
  }).catch(error => {
323
- delete handlerRequests[key];
307
+ delete this._requests[id];
324
308
  /**
325
309
  * Let's cache the error!
326
310
  *
327
311
  * NOTE: This only caches when we're server side.
328
312
  */
329
313
 
330
- return cacheError(handler, options, error);
314
+ return cacheError(id, error, _hydrate);
331
315
  });
332
- handlerRequests[key] = request;
316
+ this._requests[id] = request;
333
317
  return request;
334
318
  } catch (e) {
335
319
  /**
336
320
  * In this case, we don't cache an inflight request, because there
337
321
  * really isn't one.
338
322
  */
339
- return Promise.resolve(cacheError(handler, options, e));
323
+ return Promise.resolve(cacheError(id, e, _hydrate));
340
324
  }
341
325
  };
342
326
 
343
- this._responseCache = responseCache || ResponseCache.Default;
327
+ this._responseCache = responseCache || SsrCache.Default;
344
328
  }
329
+ /**
330
+ * Get a promise of a request for a given handler and options.
331
+ *
332
+ * This will return an inflight request if one exists, otherwise it will
333
+ * make a new request. Inflight requests are deleted once they resolve.
334
+ */
335
+
345
336
 
346
337
  }
347
338
 
@@ -378,48 +369,31 @@ class RequestTracker {
378
369
 
379
370
 
380
371
  constructor(responseCache = undefined) {
381
- this._trackedHandlers = {};
382
372
  this._trackedRequests = {};
383
373
 
384
- this.trackDataRequest = (handler, options) => {
385
- const key = handler.getKey(options);
386
- const type = handler.type;
387
- /**
388
- * Make sure we have stored the handler for use when fulfilling requests.
389
- */
390
-
391
- if (this._trackedHandlers[type] == null) {
392
- this._trackedHandlers[type] = handler;
393
- this._trackedRequests[type] = {};
394
- }
374
+ this.trackDataRequest = (id, handler, hydrate) => {
395
375
  /**
396
376
  * If we don't already have this tracked, then let's track it.
397
377
  */
398
-
399
-
400
- if (this._trackedRequests[type][key] == null) {
401
- this._trackedRequests[type][key] = options;
378
+ if (this._trackedRequests[id] == null) {
379
+ this._trackedRequests[id] = {
380
+ handler,
381
+ hydrate
382
+ };
402
383
  }
403
384
  };
404
385
 
405
386
  this.reset = () => {
406
- this._trackedHandlers = {};
407
387
  this._trackedRequests = {};
408
388
  };
409
389
 
410
390
  this.fulfillTrackedRequests = () => {
411
391
  const promises = [];
412
392
 
413
- for (const handlerType of Object.keys(this._trackedHandlers)) {
414
- const handler = this._trackedHandlers[handlerType]; // For each handler, we will perform the request fulfillments!
415
-
416
- const requests = this._trackedRequests[handlerType];
417
-
418
- for (const requestKey of Object.keys(requests)) {
419
- const promise = this._requestFulfillment.fulfill(handler, requests[requestKey]);
393
+ for (const requestKey of Object.keys(this._trackedRequests)) {
394
+ const promise = this._requestFulfillment.fulfill(requestKey, this._trackedRequests[requestKey]);
420
395
 
421
- promises.push(promise);
422
- }
396
+ promises.push(promise);
423
397
  }
424
398
  /**
425
399
  * Clear out our tracked info.
@@ -448,7 +422,7 @@ class RequestTracker {
448
422
  return Promise.all(promises).then(() => this._responseCache.cloneHydratableData());
449
423
  };
450
424
 
451
- this._responseCache = responseCache || ResponseCache.Default;
425
+ this._responseCache = responseCache || SsrCache.Default;
452
426
  this._requestFulfillment = new RequestFulfillment(responseCache);
453
427
  }
454
428
  /**
@@ -475,47 +449,13 @@ class RequestTracker {
475
449
  * Calling this method marks tracked requests as fulfilled; requests are
476
450
  * removed from the list of tracked requests by calling this method.
477
451
  *
478
- * @returns {Promise<Cache>} A frozen cache of the data that was cached
479
- * as a result of fulfilling the tracked requests.
452
+ * @returns {Promise<ResponseCache>} The promise of the data that was
453
+ * cached as a result of fulfilling the tracked requests.
480
454
  */
481
455
 
482
456
 
483
457
  }
484
458
 
485
- /**
486
- * Base implementation for creating a request handler.
487
- *
488
- * Provides a base implementation of the `IRequestHandler` base class for
489
- * use with the Wonder Blocks Data framework.
490
- */
491
- class RequestHandler {
492
- constructor(type, hydrate = true) {
493
- this._type = type;
494
- this._hydrate = !!hydrate;
495
- }
496
-
497
- get type() {
498
- return this._type;
499
- }
500
-
501
- get hydrate() {
502
- return this._hydrate;
503
- }
504
-
505
- getKey(options) {
506
- try {
507
- return options === undefined ? "undefined" : JSON.stringify(options);
508
- } catch (e) {
509
- throw new Error(`Failed to auto-generate key: ${e}`);
510
- }
511
- }
512
-
513
- fulfillRequest(options) {
514
- throw new Error("Not implemented");
515
- }
516
-
517
- }
518
-
519
459
  /**
520
460
  * Component to enable data request tracking when server-side rendering.
521
461
  */
@@ -533,16 +473,50 @@ class TrackData extends React.Component {
533
473
  }
534
474
 
535
475
  /**
536
- * InterceptContext defines a map from handler type to interception methods.
476
+ * InterceptContext defines a map from request ID to interception methods.
537
477
  *
538
478
  * INTERNAL USE ONLY
539
479
  */
540
480
  const InterceptContext = /*#__PURE__*/React.createContext({});
541
481
 
482
+ /**
483
+ * Hook to perform an asynchronous action during server-side rendering.
484
+ *
485
+ * This hook registers an asynchronous action to be performed during
486
+ * server-side rendering. The action is performed only once, and the result
487
+ * is cached against the given identifier so that subsequent calls return that
488
+ * cached result allowing components to render more of the component.
489
+ *
490
+ * This hook requires the Wonder Blocks Data functionality for resolving
491
+ * pending requests, as well as support for the hydration cache to be
492
+ * embedded into a page so that the result can by hydrated (if that is a
493
+ * requirement).
494
+ *
495
+ * The asynchronous action is never invoked on the client-side.
496
+ */
497
+ const useServerEffect = (id, handler, hydrate = true) => {
498
+ // If we're server-side or hydrating, we'll have a cached entry to use.
499
+ // So we get that and use it to initialize our state.
500
+ // This works in both hydration and SSR because the very first call to
501
+ // this will have cached data in those cases as it will be present on the
502
+ // initial render - and subsequent renders on the client it will be null.
503
+ const cachedResult = SsrCache.Default.getEntry(id); // We only track data requests when we are server-side and we don't
504
+ // already have a result, as given by the cachedData (which is also the
505
+ // initial value for the result state).
506
+
507
+ const maybeTrack = useContext(TrackerContext);
508
+
509
+ if (cachedResult == null && Server.isServerSide()) {
510
+ maybeTrack == null ? void 0 : maybeTrack(id, handler, hydrate);
511
+ }
512
+
513
+ return cachedResult;
514
+ };
515
+
542
516
  /**
543
517
  * Turns a cache entry into a stateful result.
544
518
  */
545
- const resultFromCacheEntry = cacheEntry => {
519
+ const resultFromCachedResponse = cacheEntry => {
546
520
  // No cache entry means we didn't load one yet.
547
521
  if (cacheEntry == null) {
548
522
  return {
@@ -555,113 +529,101 @@ const resultFromCacheEntry = cacheEntry => {
555
529
  error
556
530
  } = cacheEntry;
557
531
 
558
- if (data != null) {
532
+ if (error != null) {
559
533
  return {
560
- status: "success",
561
- data
534
+ status: "error",
535
+ error
562
536
  };
563
537
  }
564
538
 
565
- if (error == null) {
566
- // We should never get here ever.
539
+ if (data != null) {
567
540
  return {
568
- status: "error",
569
- error: "Loaded result has invalid state where data and error are missing"
541
+ status: "success",
542
+ data
570
543
  };
571
544
  }
572
545
 
573
546
  return {
574
- status: "error",
575
- error
547
+ status: "aborted"
576
548
  };
577
549
  };
578
550
 
579
- const useData = (handler, options) => {
580
- // If we're server-side or hydrating, we'll have a cached entry to use.
581
- // So we get that and use it to initialize our state.
582
- // This works in both hydration and SSR because the very first call to
583
- // this will have cached data in those cases as it will be present on the
584
- // initial render - and subsequent renders on the client it will be null.
585
- const cachedResult = ResponseCache.Default.getEntry(handler, options);
586
- const [result, setResult] = useState(cachedResult); // Lookup to see if there's an interceptor for the handler.
551
+ /**
552
+ * This component is the main component of Wonder Blocks Data. With this, data
553
+ * requirements can be placed in a React application in a manner that will
554
+ * support server-side rendering and efficient caching.
555
+ */
556
+ const Data = ({
557
+ requestId,
558
+ handler,
559
+ children,
560
+ hydrate,
561
+ showOldDataWhileLoading,
562
+ alwaysRequestOnHydration
563
+ }) => {
564
+ // Lookup to see if there's an interceptor for the handler.
587
565
  // If we have one, we need to replace the handler with one that
588
566
  // uses the interceptor.
567
+ const interceptorMap = React.useContext(InterceptContext); // If we have an interceptor, we need to replace the handler with one
568
+ // that uses the interceptor. This helper function generates a new
569
+ // handler.
589
570
 
590
- const interceptorMap = useContext(InterceptContext);
591
- const interceptor = interceptorMap[handler.type]; // If we have an interceptor, we need to replace the handler with one that
592
- // uses the interceptor. This helper function generates a new handler.
593
- // We need this before we track the request as we want the interceptor
594
- // to also work for tracked requests to simplify testing the server-side
595
- // request fulfillment.
571
+ const maybeInterceptedHandler = React.useMemo(() => {
572
+ const interceptor = interceptorMap[requestId];
596
573
 
597
- const getMaybeInterceptedHandler = () => {
598
574
  if (interceptor == null) {
599
575
  return handler;
600
576
  }
601
577
 
602
- const fulfillRequestFn = options => {
603
- var _interceptor$fulfillR;
604
-
605
- return (_interceptor$fulfillR = interceptor.fulfillRequest(options)) != null ? _interceptor$fulfillR : handler.fulfillRequest(options);
606
- };
578
+ return () => {
579
+ var _interceptor;
607
580
 
608
- return {
609
- fulfillRequest: fulfillRequestFn,
610
- getKey: options => handler.getKey(options),
611
- type: handler.type,
612
- hydrate: handler.hydrate
581
+ return (_interceptor = interceptor()) != null ? _interceptor : handler();
613
582
  };
614
- }; // We only track data requests when we are server-side and we don't
615
- // already have a result, as given by the cachedData (which is also the
616
- // initial value for the result state).
617
-
618
-
619
- const maybeTrack = useContext(TrackerContext);
620
-
621
- if (result == null && Server.isServerSide()) {
622
- maybeTrack == null ? void 0 : maybeTrack(getMaybeInterceptedHandler(), options);
623
- } // We need to update our request when the handler changes or the key
624
- // to the options change, so we keep track of those.
625
- // However, even if we are hydrating from cache, we still need to make the
626
- // request at least once, so we do not initialize these references.
627
-
628
-
629
- const handlerRef = useRef();
630
- const keyRef = useRef();
631
- const interceptorRef = useRef(); // This effect will ensure that we fulfill the request as desired.
632
-
633
- useEffect(() => {
634
- // If we are server-side, then just skip the effect. We track requests
635
- // during SSR and fulfill them outside of the React render cycle.
636
- // NOTE: This shouldn't happen since effects would not run on the server
637
- // but let's be defensive - I think it makes the code clearer.
638
-
639
- /* istanbul ignore next */
583
+ }, [handler, interceptorMap, requestId]);
584
+ const hydrateResult = useServerEffect(requestId, maybeInterceptedHandler, hydrate);
585
+ const [currentResult, setResult] = React.useState(hydrateResult); // Here we make sure the request still occurs client-side as needed.
586
+ // This is for legacy usage that expects this. Eventually we will want
587
+ // to deprecate.
588
+
589
+ React.useEffect(() => {
590
+ // This is here until I can do a better documentation example for
591
+ // the TrackData docs.
592
+ // istanbul ignore next
640
593
  if (Server.isServerSide()) {
641
594
  return;
642
- } // Update our refs to the current handler and key.
595
+ } // We don't bother with this if we have hydration data and we're not
596
+ // forcing a request on hydration.
597
+ // We don't care if these things change after the first render,
598
+ // so we don't want them in the inputs array.
643
599
 
644
600
 
645
- handlerRef.current = handler;
646
- keyRef.current = handler.getKey(options);
647
- interceptorRef.current = interceptor; // If we're not hydrating a result, we want to make sure we set our
601
+ if (!alwaysRequestOnHydration && (hydrateResult == null ? void 0 : hydrateResult.data) != null) {
602
+ return;
603
+ } // If we're not hydrating a result and we're not going to render
604
+ // with old data until we're loaded, we want to make sure we set our
648
605
  // result to null so that we're in the loading state.
649
606
 
650
- if (cachedResult == null) {
607
+
608
+ if (!showOldDataWhileLoading) {
651
609
  // Mark ourselves as loading.
652
610
  setResult(null);
653
611
  } // We aren't server-side, so let's make the request.
654
- // The request handler is in control of whether that request actually
655
- // happens or not.
612
+ // We don't need to use our built-in request fulfillment here if we
613
+ // don't want, but it does mean we'll share inflight requests for the
614
+ // same ID and the result will be in the same format as the
615
+ // hydrated value.
656
616
 
657
617
 
658
618
  let cancel = false;
659
- RequestFulfillment.Default.fulfill(getMaybeInterceptedHandler(), options).then(updateEntry => {
619
+ RequestFulfillment.Default.fulfill(requestId, {
620
+ handler: maybeInterceptedHandler
621
+ }).then(result => {
660
622
  if (cancel) {
661
623
  return;
662
624
  }
663
625
 
664
- setResult(updateEntry);
626
+ setResult(result);
665
627
  return;
666
628
  }).catch(e => {
667
629
  if (cancel) {
@@ -676,68 +638,130 @@ const useData = (handler, options) => {
676
638
 
677
639
  console.error(`Unexpected error occurred during data fulfillment: ${e}`);
678
640
  setResult({
679
- data: null,
680
641
  error: typeof e === "string" ? e : e.message
681
642
  });
682
643
  return;
683
644
  });
684
645
  return () => {
685
646
  cancel = true;
686
- }; // - handler.getKey is a proxy for options
687
- // - We don't want to trigger on cachedResult changing, we're
688
- // just using that as a flag for render state if the other things
689
- // trigger this effect.
647
+ }; // If the handler changes, we don't care. The ID is what indicates
648
+ // the request that should be made and folks shouldn't be changing the
649
+ // handler without changing the ID as well.
650
+ // In addition, we don't want to include hydrateResult nor
651
+ // alwaysRequestOnHydration as them changinng after the first pass
652
+ // is irrelevant.
653
+ // Finally, we don't want to include showOldDataWhileLoading as that
654
+ // changing on its own is also not relevant. It only matters if the
655
+ // request itself changes. All of which is to say that we only
656
+ // run this effect for the ID changing.
690
657
  // eslint-disable-next-line react-hooks/exhaustive-deps
691
- }, [handler, handler.getKey(options), interceptor]);
692
- return resultFromCacheEntry(result);
658
+ }, [requestId]);
659
+ return children(resultFromCachedResponse(currentResult));
693
660
  };
694
661
 
695
662
  /**
696
- * This component is the main component of Wonder Blocks Data. With this, data
697
- * requirements can be placed in a React application in a manner that will
698
- * support server-side rendering and efficient caching.
699
- */
700
- const Data = props => {
701
- const data = useData(props.handler, props.options);
702
- return props.children(data);
703
- };
704
-
705
- /**
706
- * This component provides a mechanism to intercept the data requests for the
707
- * type of a given handler and provide alternative results. This is mostly
708
- * useful for testing.
663
+ * This component provides a mechanism to intercept data requests.
664
+ * This is for use in testing.
709
665
  *
710
666
  * This component is not recommended for use in production code as it
711
667
  * can prevent predictable functioning of the Wonder Blocks Data framework.
712
668
  * One possible side-effect is that inflight requests from the interceptor could
713
- * be picked up by `Data` component requests of the same handler type from
714
- * outside the children of this component.
669
+ * be picked up by `Data` component requests from outside the children of this
670
+ * component.
715
671
  *
716
672
  * These components do not chain. If a different `InterceptData` instance is
717
- * rendered within this one that intercepts the same handler type, then that
673
+ * rendered within this one that intercepts the same id, then that
718
674
  * new instance will replace this interceptor for its children. All methods
719
675
  * will be replaced.
720
676
  */
721
- class InterceptData extends React.Component {
722
- render() {
723
- return /*#__PURE__*/React.createElement(InterceptContext.Consumer, null, value => {
724
- const handlerType = this.props.handler.type;
677
+ const InterceptData = ({
678
+ requestId,
679
+ handler,
680
+ children
681
+ }) => {
682
+ const interceptMap = React.useContext(InterceptContext);
683
+ const updatedInterceptMap = React.useMemo(() => _extends({}, interceptMap, {
684
+ [requestId]: handler
685
+ }), [interceptMap, requestId, handler]);
686
+ return /*#__PURE__*/React.createElement(InterceptContext.Provider, {
687
+ value: updatedInterceptMap
688
+ }, children);
689
+ };
725
690
 
726
- const interceptor = _extends({}, value[handlerType], {
727
- fulfillRequest: this.props.fulfillRequest
728
- });
691
+ /**
692
+ * This is the cache.
693
+ * It's incredibly complex.
694
+ * Very in-memory. So cache. Such complex. Wow.
695
+ */
696
+ const cache = new ScopedInMemoryCache();
697
+ /**
698
+ * Clear the in-memory cache or a single scope within it.
699
+ */
729
700
 
730
- const newValue = _extends({}, value, {
731
- [handlerType]: interceptor
732
- });
701
+ const clearSharedCache = (scope = "") => {
702
+ // If we have a valid scope (empty string is falsy), then clear that scope.
703
+ if (scope && typeof scope === "string") {
704
+ cache.purgeScope(scope);
705
+ } else {
706
+ // Just reset the object. This should be sufficient.
707
+ cache.purgeAll();
708
+ }
709
+ };
710
+ /**
711
+ * Hook to retrieve data from and store data in an in-memory cache.
712
+ *
713
+ * @returns {[?ReadOnlyCacheValue, CacheValueFn]}
714
+ * Returns an array containing the current cache entry (or undefined), a
715
+ * function to set the cache entry (passing null or undefined to this function
716
+ * will delete the entry).
717
+ *
718
+ * To clear a single scope within the cache or the entire cache,
719
+ * the `clearScopedCache` export is available.
720
+ *
721
+ * NOTE: Unlike useState or useReducer, we don't automatically update folks
722
+ * if the value they reference changes. We might add it later (if we need to),
723
+ * but the likelihood here is that things won't be changing in this cache in a
724
+ * way where we would need that. If we do (and likely only in specific
725
+ * circumstances), we should consider adding a simple boolean useState that can
726
+ * be toggled to cause a rerender whenever the referenced cached data changes
727
+ * so that callers can re-render on cache changes. However, we should make
728
+ * sure this toggling is optional - or we could use a callback argument, to
729
+ * achieve this on an as-needed basis.
730
+ */
733
731
 
734
- return /*#__PURE__*/React.createElement(InterceptContext.Provider, {
735
- value: newValue
736
- }, this.props.children);
737
- });
732
+ const useSharedCache = (id, scope, initialValue) => {
733
+ // Verify arguments.
734
+ if (!id || typeof id !== "string") {
735
+ throw new KindError("id must be a non-empty string", Errors.InvalidInput);
738
736
  }
739
737
 
740
- }
738
+ if (!scope || typeof scope !== "string") {
739
+ throw new KindError("scope must be a non-empty string", Errors.InvalidInput);
740
+ } // Memoize our APIs.
741
+ // This one allows callers to set or replace the cached value.
742
+
743
+
744
+ const cacheValue = React.useMemo(() => value => value == null ? cache.purge(scope, id) : cache.set(scope, id, value), [id, scope]); // We don't memo-ize the current value, just in case the cache was updated
745
+ // since our last run through. Also, our cache does not know what type it
746
+ // stores, so we have to cast it to the type we're exporting. This is a
747
+ // dev time courtesy, rather than a runtime thing.
748
+ // $FlowIgnore[incompatible-type]
749
+
750
+ let currentValue = cache.get(scope, id); // If we have an initial value, we need to add it to the cache
751
+ // and use it as our current value.
752
+
753
+ if (currentValue == null && initialValue !== undefined) {
754
+ // Get the initial value.
755
+ const value = typeof initialValue === "function" ? initialValue() : initialValue; // Update the cache.
756
+
757
+ cacheValue(value); // Make sure we return this value as our current value.
758
+
759
+ currentValue = value;
760
+ } // Now we have everything, let's return it.
761
+
762
+
763
+ return [currentValue, cacheValue];
764
+ };
741
765
 
742
766
  const GqlRouterContext = /*#__PURE__*/React.createContext(null);
743
767
 
@@ -922,7 +946,7 @@ const useGql = () => {
922
946
  return gqlFetch;
923
947
  };
924
948
 
925
- const initializeCache = source => ResponseCache.Default.initialize(source);
949
+ const initializeCache = source => SsrCache.Default.initialize(source);
926
950
  const fulfillAllDataRequests = () => {
927
951
  if (!Server.isServerSide()) {
928
952
  return Promise.reject(new Error("Data requests are not tracked when client-side"));
@@ -937,7 +961,7 @@ const hasUnfulfilledRequests = () => {
937
961
 
938
962
  return RequestTracker.Default.hasUnfulfilledRequests;
939
963
  };
940
- const removeFromCache = (handler, options) => ResponseCache.Default.remove(handler, options);
941
- const removeAllFromCache = (handler, predicate) => ResponseCache.Default.removeAll(handler, predicate);
964
+ const removeFromCache = id => SsrCache.Default.remove(id);
965
+ const removeAllFromCache = predicate => SsrCache.Default.removeAll(predicate);
942
966
 
943
- export { Data, GqlError, GqlErrors, GqlRouter, InterceptData, RequestHandler, TrackData, fulfillAllDataRequests, hasUnfulfilledRequests, initializeCache, removeAllFromCache, removeFromCache, useData, useGql };
967
+ export { Data, GqlError, GqlErrors, GqlRouter, InterceptData, ScopedInMemoryCache, TrackData, clearSharedCache, fulfillAllDataRequests, hasUnfulfilledRequests, initializeCache, removeAllFromCache, removeFromCache, useGql, useServerEffect, useSharedCache };