@khanacademy/wonder-blocks-data 2.3.3 → 3.1.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 (50) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/es/index.js +365 -429
  3. package/dist/index.js +455 -461
  4. package/docs.md +19 -13
  5. package/package.json +6 -6
  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__/gql-router.test.js +64 -0
  10. package/src/components/__tests__/intercept-data.test.js +9 -66
  11. package/src/components/__tests__/track-data.test.js +6 -5
  12. package/src/components/data.js +9 -119
  13. package/src/components/data.md +38 -60
  14. package/src/components/gql-router.js +66 -0
  15. package/src/components/intercept-context.js +2 -3
  16. package/src/components/intercept-data.js +2 -34
  17. package/src/components/intercept-data.md +7 -105
  18. package/src/hooks/__tests__/use-data.test.js +826 -0
  19. package/src/hooks/__tests__/use-gql.test.js +233 -0
  20. package/src/hooks/use-data.js +143 -0
  21. package/src/hooks/use-gql.js +75 -0
  22. package/src/index.js +7 -9
  23. package/src/util/__tests__/get-gql-data-from-response.test.js +187 -0
  24. package/src/util/__tests__/memory-cache.test.js +134 -35
  25. package/src/util/__tests__/request-fulfillment.test.js +21 -36
  26. package/src/util/__tests__/request-handler.test.js +30 -30
  27. package/src/util/__tests__/request-tracking.test.js +29 -30
  28. package/src/util/__tests__/response-cache.test.js +521 -561
  29. package/src/util/__tests__/result-from-cache-entry.test.js +68 -0
  30. package/src/util/get-gql-data-from-response.js +69 -0
  31. package/src/util/gql-error.js +36 -0
  32. package/src/util/gql-router-context.js +6 -0
  33. package/src/util/gql-types.js +60 -0
  34. package/src/util/memory-cache.js +20 -15
  35. package/src/util/request-fulfillment.js +4 -0
  36. package/src/util/request-handler.js +4 -28
  37. package/src/util/request-handler.md +0 -32
  38. package/src/util/request-tracking.js +2 -3
  39. package/src/util/response-cache.js +50 -110
  40. package/src/util/result-from-cache-entry.js +38 -0
  41. package/src/util/types.js +14 -35
  42. package/LICENSE +0 -21
  43. package/src/components/__tests__/intercept-cache.test.js +0 -124
  44. package/src/components/__tests__/internal-data.test.js +0 -1030
  45. package/src/components/intercept-cache.js +0 -79
  46. package/src/components/intercept-cache.md +0 -103
  47. package/src/components/internal-data.js +0 -219
  48. package/src/util/__tests__/no-cache.test.js +0 -112
  49. package/src/util/no-cache.js +0 -66
  50. package/src/util/no-cache.md +0 -66
package/dist/es/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Server } from '@khanacademy/wonder-blocks-core';
2
- import { createContext, Component, createElement } from 'react';
2
+ import * as React from 'react';
3
+ import { useState, useContext, useRef, useEffect, useMemo } from 'react';
3
4
  import _extends from '@babel/runtime/helpers/extends';
5
+ import { Errors, KindError } from '@khanacademy/wonder-stuff-core';
4
6
 
5
7
  function deepClone(source) {
6
8
  /**
@@ -19,9 +21,7 @@ function deepClone(source) {
19
21
  *
20
22
  * Special case cache implementation for the memory cache.
21
23
  *
22
- * This is only used within our framework. Handlers don't need to
23
- * provide this as a custom cache as the framework will default to this in the
24
- * absence of a custom cache. We use this for SSR too (see ./response-cache.js).
24
+ * This is only used within our framework for SSR (see ./response-cache.js).
25
25
  */
26
26
 
27
27
 
@@ -29,7 +29,7 @@ class MemoryCache {
29
29
  constructor(source = null) {
30
30
  this.store = (handler, options, entry) => {
31
31
  const requestType = handler.type;
32
- const frozenEntry = Object.isFrozen(entry) ? entry : Object.freeze(entry); // Ensure we have a cache location for this handler type.
32
+ const frozenEntry = Object.freeze(entry); // Ensure we have a cache location for this handler type.
33
33
 
34
34
  this._cache[requestType] = this._cache[requestType] || {}; // Cache the data.
35
35
 
@@ -89,16 +89,22 @@ class MemoryCache {
89
89
 
90
90
  if (!handlerCache) {
91
91
  return 0;
92
- } // Apply the predicate to what we have cached.
93
-
92
+ }
94
93
 
95
94
  let removedCount = 0;
96
95
 
97
- for (const [key, entry] of Object.entries(handlerCache)) {
98
- if (typeof predicate !== "function" || predicate(key, entry)) {
99
- removedCount++;
100
- delete handlerCache[key];
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
+ }
101
103
  }
104
+ } else {
105
+ // We're removing everything so delete the entire subcache.
106
+ removedCount = Object.keys(handlerCache).length;
107
+ delete this._cache[requestType];
102
108
  }
103
109
 
104
110
  return removedCount;
@@ -128,6 +134,12 @@ class MemoryCache {
128
134
  }
129
135
  }
130
136
  }
137
+ /**
138
+ * Indicate if this cache is being used or now.
139
+ *
140
+ * When the cache has entries, returns `true`; otherwise, returns `false`.
141
+ */
142
+
131
143
 
132
144
  get inUse() {
133
145
  return Object.keys(this._cache).length > 0;
@@ -135,47 +147,11 @@ class MemoryCache {
135
147
 
136
148
  }
137
149
 
138
- let defaultInstance = null;
139
- /**
140
- * This is a cache implementation to use when no caching is wanted.
141
- *
142
- * Use this with your request handler if you want to support server-side
143
- * rendering of your data requests, but want to ensure data is never cached
144
- * on the client-side.
145
- *
146
- * This is better than having `shouldRefreshCache` always return `true` in the
147
- * handler as this ensures that cache space and memory are never used for the
148
- * requested data after hydration has finished.
149
- */
150
-
151
- class NoCache {
152
- constructor() {
153
- this.store = (handler, options, entry) => {
154
- /* empty */
155
- };
156
-
157
- this.retrieve = (handler, options) => null;
158
-
159
- this.remove = (handler, options) => false;
160
-
161
- this.removeAll = (handler, predicate) => 0;
162
- }
163
-
164
- static get Default() {
165
- if (defaultInstance == null) {
166
- defaultInstance = new NoCache();
167
- }
168
-
169
- return defaultInstance;
170
- }
171
-
172
- }
173
-
174
150
  /**
175
151
  * The default instance is stored here.
176
152
  * It's created below in the Default() static property.
177
153
  */
178
- let _default;
154
+ let _default$2;
179
155
  /**
180
156
  * Implements the response cache.
181
157
  *
@@ -185,31 +161,29 @@ let _default;
185
161
 
186
162
  class ResponseCache {
187
163
  static get Default() {
188
- if (!_default) {
189
- _default = new ResponseCache();
164
+ if (!_default$2) {
165
+ _default$2 = new ResponseCache();
190
166
  }
191
167
 
192
- return _default;
168
+ return _default$2;
193
169
  }
194
170
 
195
- constructor(memoryCache = null, ssrOnlyCache = null) {
171
+ constructor(hydrationCache = null, ssrOnlyCache = null) {
196
172
  this.initialize = source => {
197
- if (this._hydrationAndDefaultCache.inUse) {
173
+ if (this._hydrationCache.inUse) {
198
174
  throw new Error("Cannot initialize data response cache more than once");
199
175
  }
200
176
 
201
177
  try {
202
- this._hydrationAndDefaultCache = new MemoryCache(source);
178
+ this._hydrationCache = new MemoryCache(source);
203
179
  } catch (e) {
204
180
  throw new Error(`An error occurred trying to initialize the data response cache: ${e}`);
205
181
  }
206
182
  };
207
183
 
208
- this.cacheData = (handler, options, data) => {
209
- return this._setCacheEntry(handler, options, {
210
- data
211
- });
212
- };
184
+ this.cacheData = (handler, options, data) => this._setCacheEntry(handler, options, {
185
+ data
186
+ });
213
187
 
214
188
  this.cacheError = (handler, options, error) => {
215
189
  const errorMessage = typeof error === "string" ? error : error.message;
@@ -219,110 +193,64 @@ class ResponseCache {
219
193
  };
220
194
 
221
195
  this.getEntry = (handler, options) => {
222
- // If we're not server-side, and the handler has a custom cache
223
- // let's try to use it.
224
- if (this._ssrOnlyCache == null && handler.cache != null) {
225
- const entry = handler.cache.retrieve(handler, options);
226
-
227
- if (entry != null) {
228
- // Custom cache has an entry, so use it.
229
- return entry;
230
- }
231
- } // Get the internal entry for the handler.
232
- // This allows us to use our hydrated cache during hydration.
233
- // If we just returned null when the custom cache didn't have it,
234
- // we would never hydrate properly.
235
-
236
-
237
- const internalEntry = this._defaultCache(handler).retrieve(handler, options); // If we are not server-side and we hydrated something that the custom
238
- // cache didn't have, we need to make sure the custom cache contains
239
- // that value.
240
-
241
-
242
- if (this._ssrOnlyCache == null && handler.cache != null && internalEntry != null) {
243
- // Yes, if this throws, we will have a problem. We want that.
244
- // Bad cache implementations should be overt.
245
- handler.cache.store(handler, options, internalEntry); // We now delete this from our in-memory cache as we don't need it.
196
+ // 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
202
+ // that from the hydration cache to save memory.
203
+
204
+ if (this._ssrOnlyCache == null && internalEntry != null) {
205
+ // We now delete this from our hydration cache as we don't need it.
246
206
  // This does mean that if another handler of the same type but
247
- // without a custom cache won't get the value, but that's not an
248
- // expected valid usage of this framework - two handlers with
249
- // different caching options shouldn't be using the same type name.
250
-
251
- this._hydrationAndDefaultCache.remove(handler, options);
207
+ // without some sort of linked cache won't get the value, but
208
+ // that's not an expected use-case. If two different places use the
209
+ // same handler and options (i.e. the same request), then the
210
+ // handler should cater to that to ensure they share the result.
211
+ this._hydrationCache.remove(handler, options);
252
212
  }
253
213
 
254
214
  return internalEntry;
255
215
  };
256
216
 
257
217
  this.remove = (handler, options) => {
218
+ var _this$_ssrOnlyCache$r, _this$_ssrOnlyCache;
219
+
258
220
  // NOTE(somewhatabstract): We could invoke removeAll with a predicate
259
221
  // to match the key of the entry we're removing, but that's an
260
222
  // inefficient way to remove a single item, so let's not do that.
261
- // If we're not server-side, and the handler has a custom cache
262
- // let's try to use it.
263
- const customCache = this._ssrOnlyCache == null ? handler.cache : null;
264
- const removedCustom = !!(customCache != null && customCache.remove(handler, options)); // Delete the entry from our internal cache.
265
- // Even if we have a custom cache, we want to make sure we still
266
- // removed the same value from internal cache since this could be
267
- // getting called before hydration for some complex advanced usage
268
- // reason.
269
-
270
- return this._defaultCache(handler).remove(handler, options) || removedCustom;
223
+ // 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;
271
225
  };
272
226
 
273
227
  this.removeAll = (handler, predicate) => {
274
- // If we're not server-side, and the handler has a custom cache
275
- // let's try to use it.
276
- const customCache = this._ssrOnlyCache == null ? handler.cache : null;
277
- const removedCountCustom = (customCache == null ? void 0 : customCache.removeAll(handler, predicate)) || 0; // Apply the predicate to what we have in our internal cached.
278
- // Even if we have a custom cache, we want to make sure we still
279
- // removed the same value from internal cache since this could be
280
- // getting called before hydration for some complex advanced usage
281
- // reason.
282
-
283
- const removedCount = this._defaultCache(handler).removeAll(handler, predicate); // We have no idea which keys were removed from which caches,
284
- // so we can't dedupe the remove counts based on keys.
285
- // That's why we return the total records deleted rather than the
286
- // total keys deleted.
287
-
288
-
289
- return removedCount + removedCountCustom;
228
+ var _this$_ssrOnlyCache$r2, _this$_ssrOnlyCache2;
229
+
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;
290
232
  };
291
233
 
292
234
  this.cloneHydratableData = () => {
293
235
  // We return our hydration cache only.
294
- return this._hydrationAndDefaultCache.cloneData();
236
+ return this._hydrationCache.cloneData();
295
237
  };
296
238
 
297
239
  this._ssrOnlyCache = Server.isServerSide() ? ssrOnlyCache || new MemoryCache() : undefined;
298
- this._hydrationAndDefaultCache = memoryCache || new MemoryCache();
299
- }
300
- /**
301
- * Returns the default cache to use for the given handler.
302
- */
303
-
304
-
305
- _defaultCache(handler) {
306
- if (handler.hydrate) {
307
- return this._hydrationAndDefaultCache;
308
- } // If the handler doesn't want to hydrate, we return the SSR-only cache.
309
- // If we are client-side, we return our non-caching implementation.
310
-
311
-
312
- return this._ssrOnlyCache || NoCache.Default;
240
+ this._hydrationCache = hydrationCache || new MemoryCache();
313
241
  }
314
242
 
315
243
  _setCacheEntry(handler, options, entry) {
316
244
  const frozenEntry = Object.freeze(entry);
317
245
 
318
- if (this._ssrOnlyCache == null && handler.cache != null) {
319
- // We are not server-side, and our handler has its own cache,
320
- // so we use that to store values.
321
- handler.cache.store(handler, options, frozenEntry);
322
- } else {
323
- // We are either server-side, or our handler doesn't provide
324
- // a caching override.
325
- this._defaultCache(handler).store(handler, options, frozenEntry);
246
+ if (this._ssrOnlyCache != null) {
247
+ // We are server-side.
248
+ // We need to store this value.
249
+ if (handler.hydrate) {
250
+ this._hydrationCache.store(handler, options, frozenEntry);
251
+ } else {
252
+ this._ssrOnlyCache.store(handler, options, frozenEntry);
253
+ }
326
254
  }
327
255
 
328
256
  return frozenEntry;
@@ -386,6 +314,8 @@ class RequestFulfillment {
386
314
  delete handlerRequests[key];
387
315
  /**
388
316
  * Let's cache the data!
317
+ *
318
+ * NOTE: This only caches when we're server side.
389
319
  */
390
320
 
391
321
  return cacheData(handler, options, data);
@@ -393,6 +323,8 @@ class RequestFulfillment {
393
323
  delete handlerRequests[key];
394
324
  /**
395
325
  * Let's cache the error!
326
+ *
327
+ * NOTE: This only caches when we're server side.
396
328
  */
397
329
 
398
330
  return cacheError(handler, options, error);
@@ -418,13 +350,13 @@ class RequestFulfillment {
418
350
  *
419
351
  * INTERNAL USE ONLY
420
352
  */
421
- const TrackerContext = new createContext(null);
353
+ const TrackerContext = new React.createContext(null);
422
354
  /**
423
355
  * The default instance is stored here.
424
356
  * It's created below in the Default() static property.
425
357
  */
426
358
 
427
- let _default$2;
359
+ let _default;
428
360
  /**
429
361
  * Implements request tracking and fulfillment.
430
362
  *
@@ -434,11 +366,11 @@ let _default$2;
434
366
 
435
367
  class RequestTracker {
436
368
  static get Default() {
437
- if (!_default$2) {
438
- _default$2 = new RequestTracker();
369
+ if (!_default) {
370
+ _default = new RequestTracker();
439
371
  }
440
372
 
441
- return _default$2;
373
+ return _default;
442
374
  }
443
375
  /**
444
376
  * These are the caches for tracked requests, their handlers, and responses.
@@ -557,9 +489,8 @@ class RequestTracker {
557
489
  * use with the Wonder Blocks Data framework.
558
490
  */
559
491
  class RequestHandler {
560
- constructor(type, cache, hydrate = true) {
492
+ constructor(type, hydrate = true) {
561
493
  this._type = type;
562
- this._cache = cache || null;
563
494
  this._hydrate = !!hydrate;
564
495
  }
565
496
 
@@ -567,26 +498,10 @@ class RequestHandler {
567
498
  return this._type;
568
499
  }
569
500
 
570
- get cache() {
571
- return this._cache;
572
- }
573
-
574
501
  get hydrate() {
575
502
  return this._hydrate;
576
503
  }
577
504
 
578
- shouldRefreshCache(options, cachedEntry) {
579
- /**
580
- * By default, the cache needs a refresh if the current entry is an
581
- * error.
582
- *
583
- * This means that an error will cause a re-request on render.
584
- * Useful if the server rendered an error, as it means the client
585
- * will update after rehydration.
586
- */
587
- return cachedEntry == null || cachedEntry.error != null;
588
- }
589
-
590
505
  getKey(options) {
591
506
  try {
592
507
  return options === undefined ? "undefined" : JSON.stringify(options);
@@ -604,13 +519,13 @@ class RequestHandler {
604
519
  /**
605
520
  * Component to enable data request tracking when server-side rendering.
606
521
  */
607
- class TrackData extends Component {
522
+ class TrackData extends React.Component {
608
523
  render() {
609
524
  if (!Server.isServerSide()) {
610
525
  throw new Error("This component is not for use during client-side rendering");
611
526
  }
612
527
 
613
- return /*#__PURE__*/createElement(TrackerContext.Provider, {
528
+ return /*#__PURE__*/React.createElement(TrackerContext.Provider, {
614
529
  value: RequestTracker.Default.trackDataRequest
615
530
  }, this.props.children);
616
531
  }
@@ -618,286 +533,180 @@ class TrackData extends Component {
618
533
  }
619
534
 
620
535
  /**
621
- * This component is responsible for actually handling the data request.
622
- * It is wrapped by Data in order to support intercepts and be exported for use.
536
+ * InterceptContext defines a map from handler type to interception methods.
623
537
  *
624
538
  * INTERNAL USE ONLY
625
539
  */
626
- class InternalData extends Component {
627
- constructor(props) {
628
- super(props);
629
- this.state = this._buildStateAndfulfillNeeds(props);
630
- }
631
-
632
- componentDidMount() {
633
- this._mounted = true;
634
- }
635
-
636
- shouldComponentUpdate(nextProps, nextState) {
637
- /**
638
- * We only bother updating if our state changed.
639
- *
640
- * And we only update the state if props changed
641
- * or we got new data/error.
642
- */
643
- if (!this._propsMatch(nextProps)) {
644
- const newState = this._buildStateAndfulfillNeeds(nextProps);
645
-
646
- this.setState(newState);
647
- }
648
-
649
- return this.state.loading !== nextState.loading || this.state.data !== nextState.data || this.state.error !== nextState.error;
650
- }
651
-
652
- componentWillUnmount() {
653
- this._mounted = false;
654
- }
655
-
656
- _propsMatch(otherProps) {
657
- const {
658
- handler,
659
- options
660
- } = this.props;
661
- const {
662
- handler: prevHandler,
663
- options: prevOptions
664
- } = otherProps;
665
- return handler === prevHandler && handler.getKey(options) === prevHandler.getKey(prevOptions);
666
- }
667
-
668
- _buildStateAndfulfillNeeds(propsAtFulfillment) {
669
- const {
670
- getEntry,
671
- handler,
672
- options
673
- } = propsAtFulfillment;
674
- const cachedData = getEntry(handler, options);
675
-
676
- if (!Server.isServerSide() && (cachedData == null || handler.shouldRefreshCache(options, cachedData))) {
677
- /**
678
- * We're not on the server, the cache missed, or our handler says
679
- * we should refresh the cache.
680
- *
681
- * Therefore, we need to request data.
682
- *
683
- * We have to do this here from the constructor so that this
684
- * data request is tracked when performing server-side rendering.
685
- */
686
- RequestFulfillment.Default.fulfill(handler, options).then(cacheEntry => {
687
- /**
688
- * We get here, we should have updated the cache.
689
- * However, we need to update the component, but we
690
- * should only do that if the props are the same as they
691
- * were when this was called.
692
- */
693
- if (this._mounted && this._propsMatch(propsAtFulfillment)) {
694
- this.setState({
695
- loading: false,
696
- data: cacheEntry.data,
697
- error: cacheEntry.error
698
- });
699
- }
700
-
701
- return null;
702
- }).catch(e => {
703
- /**
704
- * We should never get here, but if we do.
705
- */
706
- // eslint-disable-next-line no-console
707
- console.error(`Unexpected error occurred during data fulfillment: ${e}`);
708
-
709
- if (this._mounted && this._propsMatch(propsAtFulfillment)) {
710
- this.setState({
711
- loading: false,
712
- data: null,
713
- error: typeof e === "string" ? e : e.message
714
- });
715
- }
716
-
717
- return null;
718
- });
719
- }
720
- /**
721
- * This is the default response for the server and for the initial
722
- * client-side render if we have cachedData.
723
- *
724
- * This ensures we don't make promises we don't want when doing
725
- * server-side rendering. Instead, we either have data from the cache
726
- * or we don't.
727
- */
728
-
540
+ const InterceptContext = /*#__PURE__*/React.createContext({});
729
541
 
542
+ /**
543
+ * Turns a cache entry into a stateful result.
544
+ */
545
+ const resultFromCacheEntry = cacheEntry => {
546
+ // No cache entry means we didn't load one yet.
547
+ if (cacheEntry == null) {
730
548
  return {
731
- loading: cachedData == null,
732
- data: cachedData && cachedData.data,
733
- error: cachedData && cachedData.error
549
+ status: "loading"
734
550
  };
735
551
  }
736
552
 
737
- _resultFromState() {
738
- const {
739
- loading,
740
- data,
741
- error
742
- } = this.state;
743
-
744
- if (loading) {
745
- return {
746
- loading: true
747
- };
748
- }
749
-
750
- if (data != null) {
751
- return {
752
- loading: false,
753
- data
754
- };
755
- }
756
-
757
- if (error == null) {
758
- // We should never get here ever.
759
- throw new Error("Loaded result has invalid state where data and error are missing");
760
- }
553
+ const {
554
+ data,
555
+ error
556
+ } = cacheEntry;
761
557
 
558
+ if (data != null) {
762
559
  return {
763
- loading: false,
764
- error
560
+ status: "success",
561
+ data
765
562
  };
766
563
  }
767
564
 
768
- _renderContent(result) {
769
- const {
770
- children
771
- } = this.props;
772
- return children(result);
773
- }
774
-
775
- _renderWithTrackingContext(result) {
776
- return /*#__PURE__*/createElement(TrackerContext.Consumer, null, track => {
777
- /**
778
- * If data tracking wasn't enabled, don't do it.
779
- */
780
- if (track != null) {
781
- track(this.props.handler, this.props.options);
782
- }
783
-
784
- return this._renderContent(result);
785
- });
565
+ if (error == null) {
566
+ // We should never get here ever.
567
+ return {
568
+ status: "error",
569
+ error: "Loaded result has invalid state where data and error are missing"
570
+ };
786
571
  }
787
572
 
788
- render() {
789
- const result = this._resultFromState(); // We only track data requests when we are server-side and we don't
790
- // already have a result. The existence of a result is indicated by the
791
- // loading flag being false.
792
-
573
+ return {
574
+ status: "error",
575
+ error
576
+ };
577
+ };
793
578
 
794
- if (result.loading && Server.isServerSide()) {
795
- return this._renderWithTrackingContext(result);
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.
587
+ // If we have one, we need to replace the handler with one that
588
+ // uses the interceptor.
589
+
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.
596
+
597
+ const getMaybeInterceptedHandler = () => {
598
+ if (interceptor == null) {
599
+ return handler;
796
600
  }
797
601
 
798
- return this._renderContent(result);
799
- }
602
+ const fulfillRequestFn = options => {
603
+ var _interceptor$fulfillR;
800
604
 
801
- }
802
-
803
- /**
804
- * InterceptContext defines a map from handler type to interception methods.
805
- *
806
- * INTERNAL USE ONLY
807
- */
808
- const InterceptContext = /*#__PURE__*/createContext({});
809
-
810
- /**
811
- * This component is the main component of Wonder Blocks Data. With this, data
812
- * requirements can be placed in a React application in a manner that will
813
- * support server-side rendering and efficient caching.
814
- */
815
- class Data extends Component {
816
- _getHandlerFromInterceptor(interceptor) {
817
- const {
818
- handler
819
- } = this.props;
820
-
821
- if (!interceptor) {
822
- return handler;
823
- }
605
+ return (_interceptor$fulfillR = interceptor.fulfillRequest(options)) != null ? _interceptor$fulfillR : handler.fulfillRequest(options);
606
+ };
824
607
 
825
- const {
826
- fulfillRequest,
827
- shouldRefreshCache
828
- } = interceptor;
829
- const fulfillRequestFn = fulfillRequest ? options => {
830
- const interceptedResult = fulfillRequest(options);
831
- return interceptedResult != null ? interceptedResult : handler.fulfillRequest(options);
832
- } : options => handler.fulfillRequest(options);
833
- const shouldRefreshCacheFn = shouldRefreshCache ? (options, cacheEntry) => {
834
- const interceptedResult = shouldRefreshCache(options, cacheEntry);
835
- return interceptedResult != null ? interceptedResult : handler.shouldRefreshCache(options, cacheEntry);
836
- } : (options, cacheEntry) => handler.shouldRefreshCache(options, cacheEntry);
837
608
  return {
838
609
  fulfillRequest: fulfillRequestFn,
839
- shouldRefreshCache: shouldRefreshCacheFn,
840
610
  getKey: options => handler.getKey(options),
841
611
  type: handler.type,
842
- cache: handler.cache,
843
612
  hydrate: handler.hydrate
844
613
  };
845
- }
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).
846
617
 
847
- _getCacheLookupFnFromInterceptor(interceptor) {
848
- const getEntry = interceptor && interceptor.getEntry;
849
618
 
850
- if (!getEntry) {
851
- return ResponseCache.Default.getEntry;
852
- }
619
+ const maybeTrack = useContext(TrackerContext);
853
620
 
854
- return (handler, options) => {
855
- // 1. Lookup the current cache value.
856
- const cacheEntry = ResponseCache.Default.getEntry(handler, options); // 2. See if our interceptor wants to override it.
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.
857
627
 
858
- const interceptedData = getEntry(options, cacheEntry); // 3. Return the appropriate response.
859
628
 
860
- return interceptedData != null ? interceptedData : cacheEntry;
861
- };
862
- }
629
+ const handlerRef = useRef();
630
+ const keyRef = useRef();
631
+ const interceptorRef = useRef(); // This effect will ensure that we fulfill the request as desired.
863
632
 
864
- render() {
865
- return /*#__PURE__*/createElement(InterceptContext.Consumer, null, value => {
866
- const handlerType = this.props.handler.type;
867
- const interceptor = value[handlerType];
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 */
640
+ if (Server.isServerSide()) {
641
+ return;
642
+ } // Update our refs to the current handler and key.
643
+
644
+
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
648
+ // result to null so that we're in the loading state.
649
+
650
+ if (cachedResult == null) {
651
+ // Mark ourselves as loading.
652
+ setResult(null);
653
+ } // 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.
868
656
 
869
- const handler = this._getHandlerFromInterceptor(interceptor);
870
657
 
871
- const getEntry = this._getCacheLookupFnFromInterceptor(interceptor);
658
+ let cancel = false;
659
+ RequestFulfillment.Default.fulfill(getMaybeInterceptedHandler(), options).then(updateEntry => {
660
+ if (cancel) {
661
+ return;
662
+ }
663
+
664
+ setResult(updateEntry);
665
+ return;
666
+ }).catch(e => {
667
+ if (cancel) {
668
+ return;
669
+ }
872
670
  /**
873
- * Need to share our types with InternalData so Flow
874
- * doesn't need to infer them and find mismatches.
875
- * However, just deriving a new component creates issues
876
- * where InternalData starts rerendering too often.
877
- * Couldn't track down why, so suppressing the error
878
- * instead.
671
+ * We should never get here as errors in fulfillment are part
672
+ * of the `then`, but if we do.
879
673
  */
674
+ // eslint-disable-next-line no-console
880
675
 
881
676
 
882
- return /*#__PURE__*/createElement(InternalData // $FlowIgnore[incompatible-type-arg]
883
- , {
884
- handler: handler,
885
- options: this.props.options,
886
- getEntry: getEntry
887
- }, result => this.props.children(result));
677
+ console.error(`Unexpected error occurred during data fulfillment: ${e}`);
678
+ setResult({
679
+ data: null,
680
+ error: typeof e === "string" ? e : e.message
681
+ });
682
+ return;
888
683
  });
889
- }
684
+ return () => {
685
+ 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.
690
+ // eslint-disable-next-line react-hooks/exhaustive-deps
691
+ }, [handler, handler.getKey(options), interceptor]);
692
+ return resultFromCacheEntry(result);
693
+ };
890
694
 
891
- }
695
+ /**
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
+ };
892
704
 
893
705
  /**
894
706
  * This component provides a mechanism to intercept the data requests for the
895
707
  * type of a given handler and provide alternative results. This is mostly
896
708
  * useful for testing.
897
709
  *
898
- * Results from this interceptor will end up in the cache. If you
899
- * wish to only override the cache, use `InterceptCache` instead.
900
- *
901
710
  * This component is not recommended for use in production code as it
902
711
  * can prevent predictable functioning of the Wonder Blocks Data framework.
903
712
  * One possible side-effect is that inflight requests from the interceptor could
@@ -909,21 +718,20 @@ class Data extends Component {
909
718
  * new instance will replace this interceptor for its children. All methods
910
719
  * will be replaced.
911
720
  */
912
- class InterceptData extends Component {
721
+ class InterceptData extends React.Component {
913
722
  render() {
914
- return /*#__PURE__*/createElement(InterceptContext.Consumer, null, value => {
723
+ return /*#__PURE__*/React.createElement(InterceptContext.Consumer, null, value => {
915
724
  const handlerType = this.props.handler.type;
916
725
 
917
726
  const interceptor = _extends({}, value[handlerType], {
918
- fulfillRequest: this.props.fulfillRequest || null,
919
- shouldRefreshCache: this.props.shouldRefreshCache || null
727
+ fulfillRequest: this.props.fulfillRequest
920
728
  });
921
729
 
922
730
  const newValue = _extends({}, value, {
923
731
  [handlerType]: interceptor
924
732
  });
925
733
 
926
- return /*#__PURE__*/createElement(InterceptContext.Provider, {
734
+ return /*#__PURE__*/React.createElement(InterceptContext.Provider, {
927
735
  value: newValue
928
736
  }, this.props.children);
929
737
  });
@@ -931,42 +739,170 @@ class InterceptData extends Component {
931
739
 
932
740
  }
933
741
 
742
+ const GqlRouterContext = /*#__PURE__*/React.createContext(null);
743
+
934
744
  /**
935
- * This component provides a mechanism to intercept cache lookups for the
936
- * type of a given handler and provide alternative values. This is mostly
937
- * useful for testing.
938
- *
939
- * This does not modify the cache in any way. If you want to intercept
940
- * requests and cache based on the intercept, then use `InterceptData`.
745
+ * Configure GraphQL routing for GraphQL hooks and components.
941
746
  *
942
- * This component is generally not suitable for use in production code as it
943
- * can prevent predictable functioning of the Wonder Blocks Data framework.
944
- *
945
- * These components do not chain. If a different `InterceptCache` instance is
946
- * rendered within this one that intercepts the same handler type, then that
947
- * new instance will replace this interceptor for its children.
747
+ * These can be nested. Components and hooks relying on the GraphQL routing
748
+ * will use the configuration from their closest ancestral GqlRouter.
948
749
  */
949
- class InterceptCache extends Component {
950
- render() {
951
- return /*#__PURE__*/createElement(InterceptContext.Consumer, null, value => {
952
- const handlerType = this.props.handler.type;
953
-
954
- const interceptor = _extends({}, value[handlerType], {
955
- getEntry: this.props.getEntry
956
- });
750
+ const GqlRouter = ({
751
+ defaultContext: thisDefaultContext,
752
+ fetch: thisFetch,
753
+ children
754
+ }) => {
755
+ // We don't care if we're nested. We always force our callers to define
756
+ // everything. It makes for a clearer API and requires less error checking
757
+ // code (assuming our flow types are correct). We also don't default fetch
758
+ // to anything - our callers can tell us what function to use quite easily.
759
+ // If code that consumes this wants more nuanced nesting, it can implement
760
+ // it within its own GqlRouter than then defers to this one.
761
+ // We want to always use the same object if things haven't changed to avoid
762
+ // over-rendering consumers of our context, let's memoize the configuration.
763
+ // By doing this, if a component under children that uses this context
764
+ // uses React.memo, we won't force it to re-render every time we render
765
+ // because we'll only change the context value if something has actually
766
+ // changed.
767
+ const configuration = React.useMemo(() => ({
768
+ fetch: thisFetch,
769
+ defaultContext: thisDefaultContext
770
+ }), [thisDefaultContext, thisFetch]);
771
+ return /*#__PURE__*/React.createElement(GqlRouterContext.Provider, {
772
+ value: configuration
773
+ }, children);
774
+ };
957
775
 
958
- const newValue = _extends({}, value, {
959
- [handlerType]: interceptor
960
- });
776
+ /**
777
+ * Error kinds for GqlError.
778
+ */
779
+ const GqlErrors = Object.freeze(_extends({}, Errors, {
780
+ Network: "Network",
781
+ Parse: "Parse",
782
+ BadResponse: "BadResponse",
783
+ ErrorResult: "ErrorResult"
784
+ }));
785
+ /**
786
+ * An error from the GQL API.
787
+ */
961
788
 
962
- return /*#__PURE__*/createElement(InterceptContext.Provider, {
963
- value: newValue
964
- }, this.props.children);
789
+ class GqlError extends KindError {
790
+ constructor(message, kind, {
791
+ metadata,
792
+ cause
793
+ } = {}) {
794
+ super(message, kind, {
795
+ metadata,
796
+ cause,
797
+ prefix: "Gql"
965
798
  });
966
799
  }
967
800
 
968
801
  }
969
802
 
803
+ /**
804
+ * Validate a GQL operation response and extract the data.
805
+ */
806
+
807
+ const getGqlDataFromResponse = async response => {
808
+ // Get the response as text, that way we can use the text in error
809
+ // messaging, should our parsing fail.
810
+ const bodyText = await response.text();
811
+ let result;
812
+
813
+ try {
814
+ result = JSON.parse(bodyText);
815
+ } catch (e) {
816
+ throw new GqlError("Failed to parse response", GqlErrors.Parse, {
817
+ metadata: {
818
+ statusCode: response.status,
819
+ bodyText
820
+ },
821
+ cause: e
822
+ });
823
+ } // Check for a bad status code.
824
+
825
+
826
+ if (response.status >= 300) {
827
+ throw new GqlError("Response unsuccessful", GqlErrors.Network, {
828
+ metadata: {
829
+ statusCode: response.status,
830
+ result
831
+ }
832
+ });
833
+ } // Check that we have a valid result payload.
834
+
835
+
836
+ if ( // Flow shouldn't be warning about this.
837
+ // $FlowIgnore[method-unbinding]
838
+ !Object.prototype.hasOwnProperty.call(result, "data") && // Flow shouldn't be warning about this.
839
+ // $FlowIgnore[method-unbinding]
840
+ !Object.prototype.hasOwnProperty.call(result, "errors")) {
841
+ throw new GqlError("Server response missing", GqlErrors.BadResponse, {
842
+ metadata: {
843
+ statusCode: response.status,
844
+ result
845
+ }
846
+ });
847
+ } // If the response payload has errors, throw an error.
848
+
849
+
850
+ if (result.errors != null && Array.isArray(result.errors) && result.errors.length > 0) {
851
+ throw new GqlError("GraphQL errors", GqlErrors.ErrorResult, {
852
+ metadata: {
853
+ statusCode: response.status,
854
+ result
855
+ }
856
+ });
857
+ } // We got here, so return the data.
858
+
859
+
860
+ return result.data;
861
+ };
862
+
863
+ /**
864
+ * Hook to obtain a gqlFetch function for performing GraphQL requests.
865
+ *
866
+ * The fetch function will resolve null if the request was aborted, otherwise
867
+ * it will resolve the data returned by the GraphQL server.
868
+ */
869
+ const useGql = () => {
870
+ // This hook only works if the `GqlRouter` has been used to setup context.
871
+ const gqlRouterContext = useContext(GqlRouterContext);
872
+
873
+ if (gqlRouterContext == null) {
874
+ throw new GqlError("No GqlRouter", GqlErrors.Internal);
875
+ }
876
+
877
+ const {
878
+ fetch,
879
+ defaultContext
880
+ } = gqlRouterContext; // Let's memoize the gqlFetch function we create based off our context.
881
+ // That way, even if the context happens to change, if its values don't
882
+ // we give the same function instance back to our callers instead of
883
+ // making a new one. That then means they can safely use the return value
884
+ // in hooks deps without fear of it triggering extra renders.
885
+
886
+ const gqlFetch = useMemo(() => (operation, options = Object.freeze({})) => {
887
+ const {
888
+ variables,
889
+ context
890
+ } = options; // Invoke the fetch and extract the data.
891
+
892
+ return fetch(operation, variables, _extends({}, defaultContext, context)).then(getGqlDataFromResponse, error => {
893
+ // Return null if the request was aborted.
894
+ // The only way to detect this reliably, it seems, is to
895
+ // check the error name and see if it's "AbortError" (this
896
+ // is also what Apollo does).
897
+ // Even then, it's reliant on the fetch supporting aborts.
898
+ if (error.name === "AbortError") {
899
+ return null;
900
+ }
901
+ });
902
+ }, [fetch, defaultContext]);
903
+ return gqlFetch;
904
+ };
905
+
970
906
  const initializeCache = source => ResponseCache.Default.initialize(source);
971
907
  const fulfillAllDataRequests = () => {
972
908
  if (!Server.isServerSide()) {
@@ -985,4 +921,4 @@ const hasUnfulfilledRequests = () => {
985
921
  const removeFromCache = (handler, options) => ResponseCache.Default.remove(handler, options);
986
922
  const removeAllFromCache = (handler, predicate) => ResponseCache.Default.removeAll(handler, predicate);
987
923
 
988
- export { Data, InterceptCache, InterceptData, NoCache, RequestHandler, TrackData, fulfillAllDataRequests, hasUnfulfilledRequests, initializeCache, removeAllFromCache, removeFromCache };
924
+ export { Data, GqlError, GqlErrors, GqlRouter, InterceptData, RequestHandler, TrackData, fulfillAllDataRequests, hasUnfulfilledRequests, initializeCache, removeAllFromCache, removeFromCache, useData, useGql };