@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
@@ -0,0 +1,68 @@
1
+ // @flow
2
+ import {resultFromCacheEntry} from "../result-from-cache-entry.js";
3
+
4
+ describe("#resultFromCacheEntry", () => {
5
+ it("should return loading status if cache entry is null", () => {
6
+ // Arrange
7
+ const cacheEntry = null;
8
+
9
+ // Act
10
+ const result = resultFromCacheEntry(cacheEntry);
11
+
12
+ // Assert
13
+ expect(result).toStrictEqual({
14
+ status: "loading",
15
+ });
16
+ });
17
+
18
+ it("should return success status if cache entry has data", () => {
19
+ // Arrange
20
+ const cacheEntry = {
21
+ data: "DATA",
22
+ error: null,
23
+ };
24
+
25
+ // Act
26
+ const result = resultFromCacheEntry(cacheEntry);
27
+
28
+ // Assert
29
+ expect(result).toStrictEqual({
30
+ status: "success",
31
+ data: "DATA",
32
+ });
33
+ });
34
+
35
+ it("should return error status if cache entry has no data and no error", () => {
36
+ // Arrange
37
+ const cacheEntry: any = {
38
+ data: null,
39
+ error: null,
40
+ };
41
+
42
+ // Act
43
+ const result = resultFromCacheEntry(cacheEntry);
44
+
45
+ // Assert
46
+ expect(result).toStrictEqual({
47
+ status: "error",
48
+ error: "Loaded result has invalid state where data and error are missing",
49
+ });
50
+ });
51
+
52
+ it("should return error status if cache entry has error", () => {
53
+ // Arrange
54
+ const cacheEntry: any = {
55
+ data: null,
56
+ error: "ERROR",
57
+ };
58
+
59
+ // Act
60
+ const result = resultFromCacheEntry(cacheEntry);
61
+
62
+ // Assert
63
+ expect(result).toStrictEqual({
64
+ status: "error",
65
+ error: "ERROR",
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,69 @@
1
+ // @flow
2
+ import {GqlError, GqlErrors} from "./gql-error.js";
3
+
4
+ /**
5
+ * Validate a GQL operation response and extract the data.
6
+ */
7
+ export const getGqlDataFromResponse = async <TData>(
8
+ response: Response,
9
+ ): Promise<TData> => {
10
+ // Get the response as text, that way we can use the text in error
11
+ // messaging, should our parsing fail.
12
+ const bodyText = await response.text();
13
+ let result;
14
+ try {
15
+ result = JSON.parse(bodyText);
16
+ } catch (e) {
17
+ throw new GqlError("Failed to parse response", GqlErrors.Parse, {
18
+ metadata: {
19
+ statusCode: response.status,
20
+ bodyText,
21
+ },
22
+ cause: e,
23
+ });
24
+ }
25
+
26
+ // Check for a bad status code.
27
+ if (response.status >= 300) {
28
+ throw new GqlError("Response unsuccessful", GqlErrors.Network, {
29
+ metadata: {
30
+ statusCode: response.status,
31
+ result,
32
+ },
33
+ });
34
+ }
35
+
36
+ // Check that we have a valid result payload.
37
+ if (
38
+ // Flow shouldn't be warning about this.
39
+ // $FlowIgnore[method-unbinding]
40
+ !Object.prototype.hasOwnProperty.call(result, "data") &&
41
+ // Flow shouldn't be warning about this.
42
+ // $FlowIgnore[method-unbinding]
43
+ !Object.prototype.hasOwnProperty.call(result, "errors")
44
+ ) {
45
+ throw new GqlError("Server response missing", GqlErrors.BadResponse, {
46
+ metadata: {
47
+ statusCode: response.status,
48
+ result,
49
+ },
50
+ });
51
+ }
52
+
53
+ // If the response payload has errors, throw an error.
54
+ if (
55
+ result.errors != null &&
56
+ Array.isArray(result.errors) &&
57
+ result.errors.length > 0
58
+ ) {
59
+ throw new GqlError("GraphQL errors", GqlErrors.ErrorResult, {
60
+ metadata: {
61
+ statusCode: response.status,
62
+ result,
63
+ },
64
+ });
65
+ }
66
+
67
+ // We got here, so return the data.
68
+ return result.data;
69
+ };
@@ -0,0 +1,36 @@
1
+ // @flow
2
+ import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
3
+ import type {Metadata} from "@khanacademy/wonder-stuff-core";
4
+
5
+ type GqlErrorOptions = {|
6
+ metadata?: ?Metadata,
7
+ cause?: ?Error,
8
+ |};
9
+
10
+ /**
11
+ * Error kinds for GqlError.
12
+ */
13
+ export const GqlErrors = Object.freeze({
14
+ ...Errors,
15
+ Network: "Network",
16
+ Parse: "Parse",
17
+ BadResponse: "BadResponse",
18
+ ErrorResult: "ErrorResult",
19
+ });
20
+
21
+ /**
22
+ * An error from the GQL API.
23
+ */
24
+ export class GqlError extends KindError {
25
+ constructor(
26
+ message: string,
27
+ kind: $Values<typeof GqlErrors>,
28
+ {metadata, cause}: GqlErrorOptions = ({}: $Shape<GqlErrorOptions>),
29
+ ) {
30
+ super(message, kind, {
31
+ metadata,
32
+ cause,
33
+ prefix: "Gql",
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,6 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import type {GqlRouterConfiguration} from "./gql-types.js";
4
+
5
+ export const GqlRouterContext: React.Context<?GqlRouterConfiguration<any>> =
6
+ React.createContext<?GqlRouterConfiguration<any>>(null);
@@ -0,0 +1,60 @@
1
+ // @flow
2
+ /**
3
+ * Operation types.
4
+ */
5
+ export type GqlOperationType = "mutation" | "query";
6
+
7
+ /**
8
+ * A GraphQL operation.
9
+ */
10
+ export type GqlOperation<
11
+ TType: GqlOperationType,
12
+ // TData is not used to define a field on this type, but it is used
13
+ // to ensure that calls using this operation will properly return the
14
+ // correct data type.
15
+ // eslint-disable-next-line no-unused-vars
16
+ TData,
17
+ // TVariables is not used to define a field on this type, but it is used
18
+ // to ensure that calls using this operation will properly consume the
19
+ // correct variables type.
20
+ // eslint-disable-next-line no-unused-vars
21
+ TVariables: {...} = Empty,
22
+ > = {
23
+ type: TType,
24
+ id: string,
25
+ // We allow other things here to be passed along to the fetch function.
26
+ // For example, we might want to pass the full query/mutation definition
27
+ // as a string here to allow that to be sent to an Apollo server that
28
+ // expects it. This is a courtesy to calling code; these additional
29
+ // values are ignored by WB Data, and passed through as-is.
30
+ ...
31
+ };
32
+
33
+ export type GqlContext = {|
34
+ [key: string]: string,
35
+ |};
36
+
37
+ /**
38
+ * Functions that make fetches of GQL operations.
39
+ */
40
+ export type FetchFn<TType, TData, TVariables: {...}, TContext: GqlContext> = (
41
+ operation: GqlOperation<TType, TData, TVariables>,
42
+ variables: ?TVariables,
43
+ context: TContext,
44
+ ) => Promise<Response>;
45
+
46
+ /**
47
+ * The configuration stored in the GqlRouterContext context.
48
+ */
49
+ export type GqlRouterConfiguration<TContext: GqlContext> = {|
50
+ fetch: FetchFn<any, any, any, any>,
51
+ defaultContext: TContext,
52
+ |};
53
+
54
+ /**
55
+ * Options for configuring a GQL fetch.
56
+ */
57
+ export type GqlFetchOptions<TVariables: {...}, TContext: GqlContext> = {|
58
+ variables?: TVariables,
59
+ context?: Partial<TContext>,
60
+ |};
@@ -25,12 +25,11 @@ function deepClone<T: {...}>(source: T | $ReadOnly<T>): $ReadOnly<T> {
25
25
  *
26
26
  * Special case cache implementation for the memory cache.
27
27
  *
28
- * This is only used within our framework. Handlers don't need to
29
- * provide this as a custom cache as the framework will default to this in the
30
- * absence of a custom cache. We use this for SSR too (see ./response-cache.js).
28
+ * This is only used within our framework for SSR (see ./response-cache.js).
31
29
  */
32
30
  export default class MemoryCache<TOptions, TData: ValidData>
33
- implements ICache<TOptions, TData> {
31
+ implements ICache<TOptions, TData>
32
+ {
34
33
  _cache: Cache;
35
34
 
36
35
  constructor(source: ?$ReadOnly<Cache> = null) {
@@ -52,6 +51,11 @@ export default class MemoryCache<TOptions, TData: ValidData>
52
51
  }
53
52
  }
54
53
 
54
+ /**
55
+ * Indicate if this cache is being used or now.
56
+ *
57
+ * When the cache has entries, returns `true`; otherwise, returns `false`.
58
+ */
55
59
  get inUse(): boolean {
56
60
  return Object.keys(this._cache).length > 0;
57
61
  }
@@ -67,9 +71,7 @@ export default class MemoryCache<TOptions, TData: ValidData>
67
71
  ): void => {
68
72
  const requestType = handler.type;
69
73
 
70
- const frozenEntry = Object.isFrozen(entry)
71
- ? entry
72
- : Object.freeze(entry);
74
+ const frozenEntry = Object.freeze(entry);
73
75
 
74
76
  // Ensure we have a cache location for this handler type.
75
77
  this._cache[requestType] = this._cache[requestType] || {};
@@ -156,16 +158,19 @@ export default class MemoryCache<TOptions, TData: ValidData>
156
158
  return 0;
157
159
  }
158
160
 
159
- // Apply the predicate to what we have cached.
160
161
  let removedCount = 0;
161
- for (const [key, entry] of Object.entries(handlerCache)) {
162
- if (
163
- typeof predicate !== "function" ||
164
- predicate(key, (entry: any))
165
- ) {
166
- removedCount++;
167
- delete handlerCache[key];
162
+ if (typeof predicate === "function") {
163
+ // Apply the predicate to what we have cached.
164
+ for (const [key, entry] of Object.entries(handlerCache)) {
165
+ if (predicate(key, (entry: any))) {
166
+ removedCount++;
167
+ delete handlerCache[key];
168
+ }
168
169
  }
170
+ } else {
171
+ // We're removing everything so delete the entire subcache.
172
+ removedCount = Object.keys(handlerCache).length;
173
+ delete this._cache[requestType];
169
174
  }
170
175
  return removedCount;
171
176
  };
@@ -76,6 +76,8 @@ export class RequestFulfillment {
76
76
  delete handlerRequests[key];
77
77
  /**
78
78
  * Let's cache the data!
79
+ *
80
+ * NOTE: This only caches when we're server side.
79
81
  */
80
82
  return cacheData<TOptions, TData>(handler, options, data);
81
83
  })
@@ -83,6 +85,8 @@ export class RequestFulfillment {
83
85
  delete handlerRequests[key];
84
86
  /**
85
87
  * Let's cache the error!
88
+ *
89
+ * NOTE: This only caches when we're server side.
86
90
  */
87
91
  return cacheError<TOptions, TData>(handler, options, error);
88
92
  });
@@ -1,5 +1,5 @@
1
1
  // @flow
2
- import type {ValidData, CacheEntry, IRequestHandler, ICache} from "./types.js";
2
+ import type {ValidData, IRequestHandler} from "./types.js";
3
3
 
4
4
  /**
5
5
  * Base implementation for creating a request handler.
@@ -8,18 +8,13 @@ import type {ValidData, CacheEntry, IRequestHandler, ICache} from "./types.js";
8
8
  * use with the Wonder Blocks Data framework.
9
9
  */
10
10
  export default class RequestHandler<TOptions, TData: ValidData>
11
- implements IRequestHandler<TOptions, TData> {
11
+ implements IRequestHandler<TOptions, TData>
12
+ {
12
13
  _type: string;
13
- _cache: ?ICache<TOptions, TData>;
14
14
  _hydrate: boolean;
15
15
 
16
- constructor(
17
- type: string,
18
- cache?: ICache<TOptions, TData>,
19
- hydrate?: boolean = true,
20
- ) {
16
+ constructor(type: string, hydrate?: boolean = true) {
21
17
  this._type = type;
22
- this._cache = cache || null;
23
18
  this._hydrate = !!hydrate;
24
19
  }
25
20
 
@@ -27,29 +22,10 @@ export default class RequestHandler<TOptions, TData: ValidData>
27
22
  return this._type;
28
23
  }
29
24
 
30
- get cache(): ?ICache<TOptions, TData> {
31
- return this._cache;
32
- }
33
-
34
25
  get hydrate(): boolean {
35
26
  return this._hydrate;
36
27
  }
37
28
 
38
- shouldRefreshCache(
39
- options: TOptions,
40
- cachedEntry: ?$ReadOnly<CacheEntry<TData>>,
41
- ): boolean {
42
- /**
43
- * By default, the cache needs a refresh if the current entry is an
44
- * error.
45
- *
46
- * This means that an error will cause a re-request on render.
47
- * Useful if the server rendered an error, as it means the client
48
- * will update after rehydration.
49
- */
50
- return cachedEntry == null || cachedEntry.error != null;
51
- }
52
-
53
29
  getKey(options: TOptions): string {
54
30
  try {
55
31
  return options === undefined
@@ -14,12 +14,6 @@ interface IRequestHandler<TOptions, TData> {
14
14
  */
15
15
  get type(): string;
16
16
 
17
- /**
18
- * A custom cache to use with data that this handler requests.
19
- * This only affects client-side caching of data.
20
- */
21
- get cache(): ?ICache<TOptions, TData>;
22
-
23
17
  /**
24
18
  * When true, server-side results are cached and hydrated in the client.
25
19
  * When false, the server-side cache is not used and results are not
@@ -29,17 +23,6 @@ interface IRequestHandler<TOptions, TData> {
29
23
  */
30
24
  get hydrate(): boolean;
31
25
 
32
- /**
33
- * Determine if the cached data should be refreshed.
34
- *
35
- * If this returns true, the framework will use the currently cached value
36
- * but also request a new value.
37
- */
38
- shouldRefreshCache(
39
- options: TOptions,
40
- cachedEntry: ?$ReadOnly<CacheEntry<TData>>,
41
- ): boolean;
42
-
43
26
  /**
44
27
  * Get the key to use for a given request. This should be idempotent for a
45
28
  * given options set if you want caching to work across requests.
@@ -52,13 +35,6 @@ The constructor requires a `type` to identify your handler. This should be uniqu
52
35
  among the handlers that are used across your application, otherwise, requests
53
36
  may be fulfilled by the wrong handler.
54
37
 
55
- There is also an optional constructor argument, `cache`, which can be used to
56
- provide a custom cache for use with data the handler fulfills. Custom caches
57
- must implement the `ICache<TOptions, TData>` interface. If this is omitted, the
58
- core Wonder Blocks Data in-memory cache will be used. If you want to avoid
59
- caching in memory, see `NoCache`, which is a caching strategy that eliminates
60
- the use of caching entirely.
61
-
62
38
  The `fulfillRequest` method of this class is not implemented and will throw if
63
39
  called. Subclasses will need to implement this method.
64
40
 
@@ -73,11 +49,3 @@ SSR process is tracking the data for hydration. An example of setting this to
73
49
  false might be when you are using Apollo Client. In that scenario, you may use
74
50
  Apollo Cache to store and hydrate the data, while using Wonder Blocks Data to
75
51
  track and fulfill any query requests made via Apollo Client.
76
-
77
- Finally, the `shouldRefreshCache` method is provided for cases where a handler
78
- may want control over cache freshness. By default, this will return `true` for
79
- error results or a missing value. However, in some cases, handlers may want to
80
- make sure cached entries are not stale, and so may return `true` from the
81
- `shouldRefreshCache` method to instruct the framework to make a new request.
82
- The existing cached value will still be used, but an updated value will be
83
- requested.
@@ -25,9 +25,8 @@ type RequestCache = {
25
25
  *
26
26
  * INTERNAL USE ONLY
27
27
  */
28
- export const TrackerContext: React.Context<?TrackerFn> = new React.createContext<?TrackerFn>(
29
- null,
30
- );
28
+ export const TrackerContext: React.Context<?TrackerFn> =
29
+ new React.createContext<?TrackerFn>(null);
31
30
 
32
31
  /**
33
32
  * The default instance is stored here.