@khanacademy/wonder-blocks-data 7.0.1 → 8.0.2

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 (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/es/index.js +286 -107
  3. package/dist/index.js +1089 -713
  4. package/package.json +1 -1
  5. package/src/__docs__/_overview_ssr_.stories.mdx +13 -13
  6. package/src/__docs__/exports.abort-inflight-requests.stories.mdx +20 -0
  7. package/src/__docs__/exports.data.stories.mdx +3 -3
  8. package/src/__docs__/{exports.fulfill-all-data-requests.stories.mdx → exports.fetch-tracked-requests.stories.mdx} +5 -5
  9. package/src/__docs__/exports.get-gql-request-id.stories.mdx +24 -0
  10. package/src/__docs__/{exports.has-unfulfilled-requests.stories.mdx → exports.has-tracked-requests-to-be-fetched.stories.mdx} +4 -4
  11. package/src/__docs__/exports.intialize-hydration-cache.stories.mdx +29 -0
  12. package/src/__docs__/exports.purge-caches.stories.mdx +23 -0
  13. package/src/__docs__/{exports.remove-all-from-cache.stories.mdx → exports.purge-hydration-cache.stories.mdx} +4 -4
  14. package/src/__docs__/{exports.clear-shared-cache.stories.mdx → exports.purge-shared-cache.stories.mdx} +4 -4
  15. package/src/__docs__/exports.track-data.stories.mdx +4 -4
  16. package/src/__docs__/exports.use-cached-effect.stories.mdx +7 -4
  17. package/src/__docs__/exports.use-gql.stories.mdx +1 -33
  18. package/src/__docs__/exports.use-server-effect.stories.mdx +1 -1
  19. package/src/__docs__/exports.use-shared-cache.stories.mdx +2 -2
  20. package/src/__docs__/types.fetch-policy.stories.mdx +44 -0
  21. package/src/__docs__/types.response-cache.stories.mdx +1 -1
  22. package/src/__tests__/generated-snapshot.test.js +5 -5
  23. package/src/components/__tests__/data.test.js +2 -6
  24. package/src/hooks/__tests__/use-cached-effect.test.js +341 -100
  25. package/src/hooks/__tests__/use-hydratable-effect.test.js +15 -9
  26. package/src/hooks/__tests__/use-shared-cache.test.js +6 -6
  27. package/src/hooks/use-cached-effect.js +169 -93
  28. package/src/hooks/use-hydratable-effect.js +8 -1
  29. package/src/hooks/use-shared-cache.js +2 -2
  30. package/src/index.js +14 -78
  31. package/src/util/__tests__/get-gql-request-id.test.js +74 -0
  32. package/src/util/__tests__/graphql-document-node-parser.test.js +542 -0
  33. package/src/util/__tests__/hydration-cache-api.test.js +35 -0
  34. package/src/util/__tests__/purge-caches.test.js +29 -0
  35. package/src/util/__tests__/request-api.test.js +188 -0
  36. package/src/util/__tests__/request-fulfillment.test.js +42 -0
  37. package/src/util/__tests__/ssr-cache.test.js +58 -60
  38. package/src/util/__tests__/to-gql-operation.test.js +42 -0
  39. package/src/util/data-error.js +6 -0
  40. package/src/util/get-gql-request-id.js +50 -0
  41. package/src/util/graphql-document-node-parser.js +133 -0
  42. package/src/util/graphql-types.js +30 -0
  43. package/src/util/hydration-cache-api.js +28 -0
  44. package/src/util/purge-caches.js +15 -0
  45. package/src/util/request-api.js +66 -0
  46. package/src/util/request-fulfillment.js +32 -12
  47. package/src/util/request-tracking.js +1 -1
  48. package/src/util/ssr-cache.js +13 -31
  49. package/src/util/to-gql-operation.js +44 -0
  50. package/src/util/types.js +31 -0
  51. package/src/__docs__/exports.intialize-cache.stories.mdx +0 -29
  52. package/src/__docs__/exports.remove-from-cache.stories.mdx +0 -25
  53. package/src/__docs__/exports.request-fulfillment.stories.mdx +0 -36
@@ -0,0 +1,30 @@
1
+ // @flow
2
+ // NOTE(somewhatabstract):
3
+ // These types are bare minimum to support document parsing. They're derived
4
+ // from graphql@14.5.8, the last version that provided flow types.
5
+ // Doing this avoids us having to take a dependency on that library just for
6
+ // these types.
7
+ export interface DefinitionNode {
8
+ +kind: string;
9
+ }
10
+
11
+ export type VariableDefinitionNode = {
12
+ +kind: "VariableDefinition",
13
+ ...
14
+ };
15
+
16
+ export interface OperationDefinitionNode extends DefinitionNode {
17
+ +kind: "OperationDefinition";
18
+ +operation: string;
19
+ +variableDefinitions: $ReadOnlyArray<VariableDefinitionNode>;
20
+ +name?: {|
21
+ +kind: mixed,
22
+ +value: string,
23
+ |};
24
+ }
25
+
26
+ export type DocumentNode = {
27
+ +kind: "Document",
28
+ +definitions: $ReadOnlyArray<DefinitionNode>,
29
+ ...
30
+ };
@@ -0,0 +1,28 @@
1
+ // @flow
2
+ import {SsrCache} from "./ssr-cache.js";
3
+
4
+ import type {ValidCacheData, CachedResponse, ResponseCache} from "./types.js";
5
+
6
+ /**
7
+ * Initialize the hydration cache.
8
+ *
9
+ * @param {ResponseCache} source The cache content to use for initializing the
10
+ * cache.
11
+ * @throws {Error} If the cache is already initialized.
12
+ */
13
+ export const initializeHydrationCache = (source: ResponseCache): void =>
14
+ SsrCache.Default.initialize(source);
15
+
16
+ /**
17
+ * Purge cached hydration responses that match the given predicate.
18
+ *
19
+ * @param {(id: string) => boolean} [predicate] The predicate to match against
20
+ * the cached hydration responses. If no predicate is provided, all cached
21
+ * hydration responses will be purged.
22
+ */
23
+ export const purgeHydrationCache = (
24
+ predicate?: (
25
+ key: string,
26
+ cacheEntry: ?$ReadOnly<CachedResponse<ValidCacheData>>,
27
+ ) => boolean,
28
+ ): void => SsrCache.Default.purgeData(predicate);
@@ -0,0 +1,15 @@
1
+ // @flow
2
+ import {purgeSharedCache} from "../hooks/use-shared-cache.js";
3
+ import {purgeHydrationCache} from "./hydration-cache-api.js";
4
+
5
+ /**
6
+ * Purge all caches managed by Wonder Blocks Data.
7
+ *
8
+ * This is a convenience method that purges the shared cache and the hydration
9
+ * cache. It is useful for testing purposes to avoid having to reason about
10
+ * which caches may have been used during a given test run.
11
+ */
12
+ export const purgeCaches = () => {
13
+ purgeSharedCache();
14
+ purgeHydrationCache();
15
+ };
@@ -0,0 +1,66 @@
1
+ // @flow
2
+ import {Server} from "@khanacademy/wonder-blocks-core";
3
+ import {RequestTracker} from "./request-tracking.js";
4
+ import {RequestFulfillment} from "./request-fulfillment.js";
5
+ import {DataError, DataErrors} from "./data-error.js";
6
+
7
+ import type {ResponseCache} from "./types.js";
8
+
9
+ const SSRCheck = () => {
10
+ if (Server.isServerSide()) {
11
+ return null;
12
+ }
13
+
14
+ if (process.env.NODE_ENV === "production") {
15
+ return new DataError("No CSR tracking", DataErrors.NotAllowed);
16
+ } else {
17
+ return new DataError(
18
+ "Data requests are not tracked for fulfillment when when client-side",
19
+ DataErrors.NotAllowed,
20
+ );
21
+ }
22
+ };
23
+
24
+ /**
25
+ * Fetches all tracked data requests.
26
+ *
27
+ * This is for use with the `TrackData` component during server-side rendering.
28
+ *
29
+ * @throws {Error} If executed outside of server-side rendering.
30
+ * @returns {Promise<void>} A promise that resolves when all tracked requests
31
+ * have been fetched.
32
+ */
33
+ export const fetchTrackedRequests = (): Promise<ResponseCache> => {
34
+ const ssrCheck = SSRCheck();
35
+ if (ssrCheck != null) {
36
+ return Promise.reject(ssrCheck);
37
+ }
38
+ return RequestTracker.Default.fulfillTrackedRequests();
39
+ };
40
+
41
+ /**
42
+ * Indicate if there are tracked requests waiting to be fetched.
43
+ *
44
+ * This is used in conjunction with `TrackData`.
45
+ *
46
+ * @throws {Error} If executed outside of server-side rendering.
47
+ * @returns {boolean} `true` if there are unfetched tracked requests;
48
+ * otherwise, `false`.
49
+ */
50
+ export const hasTrackedRequestsToBeFetched = (): boolean => {
51
+ const ssrCheck = SSRCheck();
52
+ if (ssrCheck != null) {
53
+ throw ssrCheck;
54
+ }
55
+ return RequestTracker.Default.hasUnfulfilledRequests;
56
+ };
57
+
58
+ /**
59
+ * Abort all in-flight requests.
60
+ *
61
+ * This aborts all requests currently inflight via our default request
62
+ * fulfillment.
63
+ */
64
+ export const abortInflightRequests = (): void => {
65
+ RequestFulfillment.Default.abortAll();
66
+ };
@@ -5,9 +5,13 @@ import {DataError, DataErrors} from "./data-error.js";
5
5
 
6
6
  type RequestCache = {
7
7
  [id: string]: Promise<Result<any>>,
8
- ...
9
8
  };
10
9
 
10
+ type FulfillOptions<TData: ValidCacheData> = {|
11
+ handler: () => Promise<TData>,
12
+ hydrate?: boolean,
13
+ |};
14
+
11
15
  let _default: RequestFulfillment;
12
16
 
13
17
  /**
@@ -31,19 +35,10 @@ export class RequestFulfillment {
31
35
  */
32
36
  fulfill: <TData: ValidCacheData>(
33
37
  id: string,
34
- options: {|
35
- handler: () => Promise<TData>,
36
- hydrate?: boolean,
37
- |},
38
+ options: FulfillOptions<TData>,
38
39
  ) => Promise<Result<TData>> = <TData: ValidCacheData>(
39
40
  id: string,
40
- {
41
- handler,
42
- hydrate = true,
43
- }: {|
44
- handler: () => Promise<TData>,
45
- hydrate?: boolean,
46
- |},
41
+ {handler, hydrate = true}: FulfillOptions<TData>,
47
42
  ): Promise<Result<TData>> => {
48
43
  /**
49
44
  * If we have an inflight request, we'll provide that.
@@ -97,4 +92,29 @@ export class RequestFulfillment {
97
92
 
98
93
  return request;
99
94
  };
95
+
96
+ /**
97
+ * Abort an inflight request.
98
+ *
99
+ * NOTE: Currently, this does not perform an actual abort. It merely
100
+ * removes the request from being tracked.
101
+ */
102
+ abort: (id: string) => void = (id) => {
103
+ // TODO(somewhatabstract, FEI-4276): Add first class abort
104
+ // support to the handler API.
105
+ // For now, we will just clear the request out of the list.
106
+ // When abort is implemented, the `finally` in the `fulfill` method
107
+ // would handle the deletion.
108
+ delete this._requests[id];
109
+ };
110
+
111
+ /**
112
+ * Abort all inflight requests.
113
+ *
114
+ * NOTE: Currently, this does not perform actual aborts. It merely
115
+ * removes the requests from our tracking.
116
+ */
117
+ abortAll: () => void = (): void => {
118
+ Object.keys(this._requests).forEach((id) => this.abort(id));
119
+ };
100
120
  }
@@ -25,7 +25,7 @@ type RequestCache = {
25
25
  * INTERNAL USE ONLY
26
26
  */
27
27
  export const TrackerContext: React.Context<?TrackerFn> =
28
- new React.createContext<?TrackerFn>(null);
28
+ React.createContext<?TrackerFn>(null);
29
29
 
30
30
  /**
31
31
  * The default instance is stored here.
@@ -26,15 +26,13 @@ export class SsrCache {
26
26
  }
27
27
 
28
28
  _hydrationCache: SerializableInMemoryCache;
29
- _ssrOnlyCache: ?SerializableInMemoryCache;
29
+ _ssrOnlyCache: SerializableInMemoryCache;
30
30
 
31
31
  constructor(
32
32
  hydrationCache: ?SerializableInMemoryCache = null,
33
33
  ssrOnlyCache: ?SerializableInMemoryCache = null,
34
34
  ) {
35
- this._ssrOnlyCache = Server.isServerSide()
36
- ? ssrOnlyCache || new SerializableInMemoryCache()
37
- : undefined;
35
+ this._ssrOnlyCache = ssrOnlyCache || new SerializableInMemoryCache();
38
36
  this._hydrationCache =
39
37
  hydrationCache || new SerializableInMemoryCache();
40
38
  }
@@ -54,7 +52,7 @@ export class SsrCache {
54
52
  // Usually, when server-side, this cache will always be present.
55
53
  // We do fake server-side in our doc example though, when it
56
54
  // won't be.
57
- this._ssrOnlyCache?.set(DefaultScope, id, frozenEntry);
55
+ this._ssrOnlyCache.set(DefaultScope, id, frozenEntry);
58
56
  }
59
57
  }
60
58
  return frozenEntry;
@@ -120,14 +118,18 @@ export class SsrCache {
120
118
  ): ?$ReadOnly<CachedResponse<TData>> => {
121
119
  // Get the cached entry for this value.
122
120
 
123
- // We first look in the ssr cache and then the hydration cache.
121
+ // We first look in the ssr cache, if we need to.
122
+ const ssrEntry = Server.isServerSide()
123
+ ? this._ssrOnlyCache.get(DefaultScope, id)
124
+ : null;
125
+
126
+ // Now we defer to the SSR value, and fallback to the hydration cache.
124
127
  const internalEntry =
125
- this._ssrOnlyCache?.get(DefaultScope, id) ??
126
- this._hydrationCache.get(DefaultScope, id);
128
+ ssrEntry ?? this._hydrationCache.get(DefaultScope, id);
127
129
 
128
130
  // If we are not server-side and we hydrated something, let's clear
129
131
  // that from the hydration cache to save memory.
130
- if (this._ssrOnlyCache == null && internalEntry != null) {
132
+ if (!Server.isServerSide() && internalEntry != null) {
131
133
  // We now delete this from our hydration cache as we don't need it.
132
134
  // This does mean that if another handler of the same type but
133
135
  // without some sort of linked cache won't get the value, but
@@ -142,26 +144,6 @@ export class SsrCache {
142
144
  return internalEntry;
143
145
  };
144
146
 
145
- /**
146
- * Remove from cache, the entry matching the given handler and options.
147
- *
148
- * This will, if present therein, remove the value from the custom cache
149
- * associated with the handler and the framework in-memory cache.
150
- *
151
- * Returns true if something was removed from any cache; otherwise, false.
152
- */
153
- remove: (id: string) => boolean = (id: string): boolean => {
154
- // NOTE(somewhatabstract): We could invoke removeAll with a predicate
155
- // to match the key of the entry we're removing, but that's an
156
- // inefficient way to remove a single item, so let's not do that.
157
-
158
- // Delete the entry from the appropriate cache.
159
- return (
160
- this._hydrationCache.purge(DefaultScope, id) ||
161
- (this._ssrOnlyCache?.purge(DefaultScope, id) ?? false)
162
- );
163
- };
164
-
165
147
  /**
166
148
  * Remove from cache, any entries matching the given handler and predicate.
167
149
  *
@@ -170,7 +152,7 @@ export class SsrCache {
170
152
  *
171
153
  * It returns a count of all records removed.
172
154
  */
173
- removeAll: (
155
+ purgeData: (
174
156
  predicate?: (
175
157
  key: string,
176
158
  cachedEntry: $ReadOnly<CachedResponse<ValidCacheData>>,
@@ -185,7 +167,7 @@ export class SsrCache {
185
167
 
186
168
  // Apply the predicate to what we have in our caches.
187
169
  this._hydrationCache.purgeAll(realPredicate);
188
- this._ssrOnlyCache?.purgeAll(realPredicate);
170
+ this._ssrOnlyCache.purgeAll(realPredicate);
189
171
  };
190
172
 
191
173
  /**
@@ -0,0 +1,44 @@
1
+ // @flow
2
+ import {graphQLDocumentNodeParser} from "./graphql-document-node-parser.js";
3
+ import type {GqlOperation} from "./gql-types.js";
4
+ import type {DocumentNode} from "./graphql-types.js";
5
+
6
+ /**
7
+ * Convert a GraphQL DocumentNode to a base Wonder Blocks Data GqlOperation.
8
+ *
9
+ * If you want to include the query/mutation body, extend the result of this
10
+ * method and use the `graphql/language/printer` like:
11
+ *
12
+ * ```js
13
+ * import {print} from "graphql/language/printer";
14
+ *
15
+ * const gqlOpWithBody = {
16
+ * ...toGqlOperation(documentNode),
17
+ * query: print(documentNode),
18
+ * };
19
+ * ```
20
+ *
21
+ * If you want to enforce inclusion of __typename properties, then you can use
22
+ * `apollo-utilities` first to modify the document:
23
+ *
24
+ * ```js
25
+ * import {print} from "graphql/language/printer";
26
+ * import {addTypenameToDocument} from "apollo-utilities";
27
+ *
28
+ * const documentWithTypenames = addTypenameToDocument(documentNode);
29
+ * const gqlOpWithBody = {
30
+ * ...toGqlOperation(documentWithTypenames),
31
+ * query: print(documentWithTypenames),
32
+ * };
33
+ * ```
34
+ */
35
+ export const toGqlOperation = <TData, TVariables: {...}>(
36
+ documentNode: DocumentNode,
37
+ ): GqlOperation<TData, TVariables> => {
38
+ const definition = graphQLDocumentNodeParser(documentNode);
39
+ const wbDataOperation: GqlOperation<TData, TVariables> = {
40
+ id: definition.name,
41
+ type: definition.type,
42
+ };
43
+ return wbDataOperation;
44
+ };
package/src/util/types.js CHANGED
@@ -1,6 +1,37 @@
1
1
  // @flow
2
2
  import type {Metadata} from "@khanacademy/wonder-stuff-core";
3
3
 
4
+ // TODO(somewhatabstract, FEI-4172): Update eslint-plugin-flowtype when
5
+ // they've fixed https://github.com/gajus/eslint-plugin-flowtype/issues/502
6
+ /* eslint-disable no-undef */
7
+ /**
8
+ * Defines the various fetch policies that can be applied to requests.
9
+ */
10
+ export enum FetchPolicy {
11
+ /**
12
+ * If the data is in the cache, return that; otherwise, fetch from the
13
+ * server.
14
+ */
15
+ CacheBeforeNetwork,
16
+
17
+ /**
18
+ * If the data is in the cache, return that; always fetch from the server
19
+ * regardless of cache.
20
+ */
21
+ CacheAndNetwork,
22
+
23
+ /**
24
+ * If the data is in the cache, return that; otherwise, do nothing.
25
+ */
26
+ CacheOnly,
27
+
28
+ /**
29
+ * Ignore any existing cached result; always fetch from the server.
30
+ */
31
+ NetworkOnly,
32
+ }
33
+ /* eslint-enable no-undef */
34
+
4
35
  /**
5
36
  * Define what can be cached.
6
37
  *
@@ -1,29 +0,0 @@
1
- import {Meta} from "@storybook/addon-docs";
2
-
3
- <Meta
4
- title="Data / Exports / initializeCache()"
5
- parameters={{
6
- chromatic: {
7
- disableSnapshot: true,
8
- },
9
- }}
10
- />
11
-
12
- # initializeCache()
13
-
14
- ```ts
15
- initializeCache(sourceCache: ResponseCache): void;
16
- ```
17
-
18
- | Argument | Flow&nbsp;Type | Default | Description |
19
- | --- | --- | --- | --- |
20
- | `sourceData` | `ResponseCache` | _Required_ | The source cache that will be used to initialize the response cache. |
21
-
22
- Wonder Blocks Data caches data in its response cache for hydration. This cache can be initialized with data using the `initializeCache` method.
23
- The `initializeCache` method can only be called when the hydration cache is empty.
24
-
25
- Usually, the data to be passed to `initializeCache` will be obtained by calling [`fulfillAllDataRequests`](/docs/data-exports-fulfillalldatarequests--page) after tracking data requests (see [`TrackData`](/docs/data-exports-trackdata--page)) during server-side rendering.
26
-
27
- Combine with [`removeFromCache`](/docs/data-exports-removefromcache--page) or [`removeAllFromCache`](/docs/data-exports-removeallfromcache--page) to support your testing needs.
28
-
29
- More details about server-side rendering with Wonder Blocks Data can be found in the [relevant overview section](/docs/data-server-side-rendering-and-hydration--page).
@@ -1,25 +0,0 @@
1
- import {Meta} from "@storybook/addon-docs";
2
-
3
- <Meta
4
- title="Data / Exports / removeFromCache()"
5
- parameters={{
6
- chromatic: {
7
- disableSnapshot: true,
8
- },
9
- }}
10
- />
11
-
12
- # removeFromCache()
13
-
14
- ```ts
15
- removeFromCache(id: string): void;
16
- ```
17
-
18
- | Argument | Flow&nbsp;Type | Default | Description |
19
- | --- | --- | --- | --- |
20
- | `id` | `string` | _Required_ | The id of the item to be removed. |
21
-
22
-
23
- Removes an entry from the cache.
24
-
25
- This can be used after [`initializeCache`](/docs/data-exports-initializecache--page) to manipulate the cache prior to hydration. This can be useful during testing.
@@ -1,36 +0,0 @@
1
- import {Meta} from "@storybook/addon-docs";
2
-
3
- <Meta
4
- title="Data / Exports / RequestFulfillment"
5
- parameters={{
6
- chromatic: {
7
- disableSnapshot: true,
8
- },
9
- }}
10
- />
11
-
12
- # RequestFulfillment
13
-
14
- The `RequestFulfillment` class encapsulates tracking of inflight asynchronous actions keyed by an identifier. Using this API, callers can request the result of the same asynchronous action multiple times without invoking the underlying action more than is necessary. Instead, any pending request will be shared by all requests for the same identifier.
15
-
16
- ## Usage
17
-
18
- ```ts
19
- fulfill: <TData: ValidCacheData>(
20
- id: string,
21
- options: {|
22
- handler: () => Promise<TData>,
23
- hydrate?: boolean,
24
- |},
25
- ) => Promise<Result<TData>>;
26
- ```
27
-
28
- There is a single function on the `RequestFulfillment` class, called `fulfill`.
29
-
30
- The `fulfill` method takes the request identifier (used to deduplicate requests) and an options object. The options object contains the following properties:
31
-
32
- * `handler`: A function that returns a promise resolving to the result of the request. This is the asynchronous work that will be tracked by the given identifier.
33
- * `hydrate`: A boolean indicating whether the data should be hydrated. This is used during server-side rendering to determine if the response data should be included in the hydration cache. This defaults to `true` and should only be set to `false` if you are performing server-side rendering of the request and you know that the data will not be needed for hydration to succeed.
34
-
35
- ## RequestFulfillment.Default
36
- The `RequestFulfillment` class provides a static instance, `RequestFulfillment.Default`, which is used by the Wonder Blocks Data framework. However, a custom instance can be constructed should your specific use case need to be isolated from others.