@khanacademy/wonder-blocks-data 2.3.4 → 3.1.1

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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/es/index.js +368 -429
  3. package/dist/index.js +457 -460
  4. package/docs.md +19 -13
  5. package/package.json +3 -3
  6. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +40 -160
  7. package/src/__tests__/generated-snapshot.test.js +15 -195
  8. package/src/components/__tests__/data.test.js +159 -965
  9. package/src/components/__tests__/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 -117
  13. package/src/components/data.md +38 -60
  14. package/src/components/gql-router.js +66 -0
  15. package/src/components/intercept-data.js +2 -34
  16. package/src/components/intercept-data.md +7 -105
  17. package/src/hooks/__tests__/use-data.test.js +826 -0
  18. package/src/hooks/__tests__/use-gql.test.js +233 -0
  19. package/src/hooks/use-data.js +143 -0
  20. package/src/hooks/use-gql.js +77 -0
  21. package/src/index.js +13 -9
  22. package/src/util/__tests__/get-gql-data-from-response.test.js +187 -0
  23. package/src/util/__tests__/memory-cache.test.js +134 -35
  24. package/src/util/__tests__/request-fulfillment.test.js +21 -36
  25. package/src/util/__tests__/request-handler.test.js +30 -30
  26. package/src/util/__tests__/request-tracking.test.js +29 -30
  27. package/src/util/__tests__/response-cache.test.js +521 -561
  28. package/src/util/__tests__/result-from-cache-entry.test.js +68 -0
  29. package/src/util/get-gql-data-from-response.js +69 -0
  30. package/src/util/gql-error.js +36 -0
  31. package/src/util/gql-router-context.js +6 -0
  32. package/src/util/gql-types.js +65 -0
  33. package/src/util/memory-cache.js +18 -14
  34. package/src/util/request-fulfillment.js +4 -0
  35. package/src/util/request-handler.js +2 -27
  36. package/src/util/request-handler.md +0 -32
  37. package/src/util/response-cache.js +50 -110
  38. package/src/util/result-from-cache-entry.js +38 -0
  39. package/src/util/types.js +14 -35
  40. package/LICENSE +0 -21
  41. package/src/components/__tests__/intercept-cache.test.js +0 -124
  42. package/src/components/__tests__/internal-data.test.js +0 -1030
  43. package/src/components/intercept-cache.js +0 -79
  44. package/src/components/intercept-cache.md +0 -103
  45. package/src/components/internal-data.js +0 -219
  46. package/src/util/__tests__/no-cache.test.js +0 -112
  47. package/src/util/no-cache.js +0 -67
  48. package/src/util/no-cache.md +0 -66
@@ -1,13 +1,11 @@
1
1
  // @flow
2
2
  import {Server} from "@khanacademy/wonder-blocks-core";
3
3
  import MemoryCache from "./memory-cache.js";
4
- import NoCache from "./no-cache.js";
5
4
 
6
5
  import type {
7
6
  ValidData,
8
7
  CacheEntry,
9
8
  Cache,
10
- ICache,
11
9
  IRequestHandler,
12
10
  ResponseCache as ResCache,
13
11
  } from "./types.js";
@@ -31,32 +29,17 @@ export class ResponseCache {
31
29
  return _default;
32
30
  }
33
31
 
34
- _hydrationAndDefaultCache: MemoryCache<any, any>;
32
+ _hydrationCache: MemoryCache<any, any>;
35
33
  _ssrOnlyCache: ?MemoryCache<any, any>;
36
34
 
37
35
  constructor(
38
- memoryCache: ?MemoryCache<any, any> = null,
36
+ hydrationCache: ?MemoryCache<any, any> = null,
39
37
  ssrOnlyCache: ?MemoryCache<any, any> = null,
40
38
  ) {
41
39
  this._ssrOnlyCache = Server.isServerSide()
42
40
  ? ssrOnlyCache || new MemoryCache()
43
41
  : undefined;
44
- this._hydrationAndDefaultCache = memoryCache || new MemoryCache();
45
- }
46
-
47
- /**
48
- * Returns the default cache to use for the given handler.
49
- */
50
- _defaultCache<TOptions, TData: ValidData>(
51
- handler: IRequestHandler<TOptions, TData>,
52
- ): ICache<TOptions, TData> {
53
- if (handler.hydrate) {
54
- return this._hydrationAndDefaultCache;
55
- }
56
-
57
- // If the handler doesn't want to hydrate, we return the SSR-only cache.
58
- // If we are client-side, we return our non-caching implementation.
59
- return this._ssrOnlyCache || NoCache.Default;
42
+ this._hydrationCache = hydrationCache || new MemoryCache();
60
43
  }
61
44
 
62
45
  _setCacheEntry<TOptions, TData: ValidData>(
@@ -65,15 +48,14 @@ export class ResponseCache {
65
48
  entry: CacheEntry<TData>,
66
49
  ): CacheEntry<TData> {
67
50
  const frozenEntry = Object.freeze(entry);
68
-
69
- if (this._ssrOnlyCache == null && handler.cache != null) {
70
- // We are not server-side, and our handler has its own cache,
71
- // so we use that to store values.
72
- handler.cache.store(handler, options, frozenEntry);
73
- } else {
74
- // We are either server-side, or our handler doesn't provide
75
- // a caching override.
76
- this._defaultCache(handler).store(handler, options, frozenEntry);
51
+ if (this._ssrOnlyCache != null) {
52
+ // We are server-side.
53
+ // We need to store this value.
54
+ if (handler.hydrate) {
55
+ this._hydrationCache.store(handler, options, frozenEntry);
56
+ } else {
57
+ this._ssrOnlyCache.store(handler, options, frozenEntry);
58
+ }
77
59
  }
78
60
  return frozenEntry;
79
61
  }
@@ -84,14 +66,14 @@ export class ResponseCache {
84
66
  * This can only be called if the cache is not already in use.
85
67
  */
86
68
  initialize: (source: ResCache) => void = (source) => {
87
- if (this._hydrationAndDefaultCache.inUse) {
69
+ if (this._hydrationCache.inUse) {
88
70
  throw new Error(
89
71
  "Cannot initialize data response cache more than once",
90
72
  );
91
73
  }
92
74
 
93
75
  try {
94
- this._hydrationAndDefaultCache = new MemoryCache(source);
76
+ this._hydrationCache = new MemoryCache(source);
95
77
  } catch (e) {
96
78
  throw new Error(
97
79
  `An error occurred trying to initialize the data response cache: ${e}`,
@@ -101,6 +83,8 @@ export class ResponseCache {
101
83
 
102
84
  /**
103
85
  * Cache data for a specific response.
86
+ *
87
+ * This is a noop when client-side.
104
88
  */
105
89
  cacheData: <TOptions, TData: ValidData>(
106
90
  handler: IRequestHandler<TOptions, TData>,
@@ -110,12 +94,12 @@ export class ResponseCache {
110
94
  handler: IRequestHandler<TOptions, TData>,
111
95
  options: TOptions,
112
96
  data: TData,
113
- ): CacheEntry<TData> => {
114
- return this._setCacheEntry(handler, options, {data});
115
- };
97
+ ): CacheEntry<TData> => this._setCacheEntry(handler, options, {data});
116
98
 
117
99
  /**
118
100
  * Cache an error for a specific response.
101
+ *
102
+ * This is a noop when client-side.
119
103
  */
120
104
  cacheError: <TOptions, TData: ValidData>(
121
105
  handler: IRequestHandler<TOptions, TData>,
@@ -140,42 +124,27 @@ export class ResponseCache {
140
124
  handler: IRequestHandler<TOptions, TData>,
141
125
  options: TOptions,
142
126
  ): ?$ReadOnly<CacheEntry<TData>> => {
143
- // If we're not server-side, and the handler has a custom cache
144
- // let's try to use it.
145
- if (this._ssrOnlyCache == null && handler.cache != null) {
146
- const entry = handler.cache.retrieve(handler, options);
147
- if (entry != null) {
148
- // Custom cache has an entry, so use it.
149
- return entry;
150
- }
151
- }
152
-
153
- // Get the internal entry for the handler.
154
- // This allows us to use our hydrated cache during hydration.
155
- // If we just returned null when the custom cache didn't have it,
156
- // we would never hydrate properly.
157
- const internalEntry = this._defaultCache<TOptions, TData>(
158
- handler,
159
- ).retrieve(handler, options);
160
-
161
- // If we are not server-side and we hydrated something that the custom
162
- // cache didn't have, we need to make sure the custom cache contains
163
- // that value.
164
- if (
165
- this._ssrOnlyCache == null &&
166
- handler.cache != null &&
167
- internalEntry != null
168
- ) {
169
- // Yes, if this throws, we will have a problem. We want that.
170
- // Bad cache implementations should be overt.
171
- handler.cache.store(handler, options, internalEntry);
127
+ // Get the cached entry for this value.
128
+ // If the handler wants WB Data to hydrate (i.e. handler.hydrate is
129
+ // true), we use our hydration cache. Otherwise, if we're server-side
130
+ // we use our SSR-only cache. Otherwise, there's no entry to return.
131
+ const cache = handler.hydrate
132
+ ? this._hydrationCache
133
+ : Server.isServerSide()
134
+ ? this._ssrOnlyCache
135
+ : undefined;
136
+ const internalEntry = cache?.retrieve(handler, options);
172
137
 
173
- // We now delete this from our in-memory cache as we don't need it.
138
+ // If we are not server-side and we hydrated something, let's clear
139
+ // that from the hydration cache to save memory.
140
+ if (this._ssrOnlyCache == null && internalEntry != null) {
141
+ // We now delete this from our hydration cache as we don't need it.
174
142
  // This does mean that if another handler of the same type but
175
- // without a custom cache won't get the value, but that's not an
176
- // expected valid usage of this framework - two handlers with
177
- // different caching options shouldn't be using the same type name.
178
- this._hydrationAndDefaultCache.remove(handler, options);
143
+ // without some sort of linked cache won't get the value, but
144
+ // that's not an expected use-case. If two different places use the
145
+ // same handler and options (i.e. the same request), then the
146
+ // handler should cater to that to ensure they share the result.
147
+ this._hydrationCache.remove(handler, options);
179
148
  }
180
149
  return internalEntry;
181
150
  };
@@ -199,31 +168,19 @@ export class ResponseCache {
199
168
  // to match the key of the entry we're removing, but that's an
200
169
  // inefficient way to remove a single item, so let's not do that.
201
170
 
202
- // If we're not server-side, and the handler has a custom cache
203
- // let's try to use it.
204
- const customCache = this._ssrOnlyCache == null ? handler.cache : null;
205
- const removedCustom: boolean = !!customCache?.remove(handler, options);
206
-
207
- // Delete the entry from our internal cache.
208
- // Even if we have a custom cache, we want to make sure we still
209
- // removed the same value from internal cache since this could be
210
- // getting called before hydration for some complex advanced usage
211
- // reason.
212
- return (
213
- this._defaultCache(handler).remove(handler, options) ||
214
- removedCustom
215
- );
171
+ // Delete the entry from the appropriate cache.
172
+ return handler.hydrate
173
+ ? this._hydrationCache.remove(handler, options)
174
+ : this._ssrOnlyCache?.remove(handler, options) ?? false;
216
175
  };
217
176
 
218
177
  /**
219
178
  * Remove from cache, any entries matching the given handler and predicate.
220
179
  *
221
- * This will, if present therein, remove matching values from the custom
222
- * cache associated with the handler and the framework in-memory cache.
180
+ * This will, if present therein, remove matching values from the framework
181
+ * in-memory cache.
223
182
  *
224
- * It returns a count of all records removed. This is not a count of unique
225
- * keys, but of unique entries. So if the same key is removed from both the
226
- * framework and custom caches, that will be 2 records removed.
183
+ * It returns a count of all records removed.
227
184
  */
228
185
  removeAll: <TOptions, TData: ValidData>(
229
186
  handler: IRequestHandler<TOptions, TData>,
@@ -238,36 +195,19 @@ export class ResponseCache {
238
195
  cachedEntry: $ReadOnly<CacheEntry<TData>>,
239
196
  ) => boolean,
240
197
  ): number => {
241
- // If we're not server-side, and the handler has a custom cache
242
- // let's try to use it.
243
- const customCache = this._ssrOnlyCache == null ? handler.cache : null;
244
- const removedCountCustom: number =
245
- customCache?.removeAll(handler, predicate) || 0;
246
-
247
- // Apply the predicate to what we have in our internal cached.
248
- // Even if we have a custom cache, we want to make sure we still
249
- // removed the same value from internal cache since this could be
250
- // getting called before hydration for some complex advanced usage
251
- // reason.
252
- const removedCount = this._defaultCache(handler).removeAll(
253
- handler,
254
- predicate,
255
- );
256
-
257
- // We have no idea which keys were removed from which caches,
258
- // so we can't dedupe the remove counts based on keys.
259
- // That's why we return the total records deleted rather than the
260
- // total keys deleted.
261
- return removedCount + removedCountCustom;
198
+ // Apply the predicate to what we have in the appropriate cache.
199
+ return handler.hydrate
200
+ ? this._hydrationCache.removeAll(handler, predicate)
201
+ : this._ssrOnlyCache?.removeAll(handler, predicate) ?? 0;
262
202
  };
263
203
 
264
204
  /**
265
205
  * Deep clone the hydration cache.
266
206
  *
267
- * By design, this does not clone anything held in custom caches.
207
+ * By design, this only clones the data that is to be used for hydration.
268
208
  */
269
209
  cloneHydratableData: () => $ReadOnly<Cache> = (): $ReadOnly<Cache> => {
270
210
  // We return our hydration cache only.
271
- return this._hydrationAndDefaultCache.cloneData();
211
+ return this._hydrationCache.cloneData();
272
212
  };
273
213
  }
@@ -0,0 +1,38 @@
1
+ // @flow
2
+ import type {ValidData, CacheEntry, Result} from "./types.js";
3
+
4
+ /**
5
+ * Turns a cache entry into a stateful result.
6
+ */
7
+ export const resultFromCacheEntry = <TData: ValidData>(
8
+ cacheEntry: ?CacheEntry<TData>,
9
+ ): Result<TData> => {
10
+ // No cache entry means we didn't load one yet.
11
+ if (cacheEntry == null) {
12
+ return {
13
+ status: "loading",
14
+ };
15
+ }
16
+
17
+ const {data, error} = cacheEntry;
18
+
19
+ if (data != null) {
20
+ return {
21
+ status: "success",
22
+ data,
23
+ };
24
+ }
25
+
26
+ if (error == null) {
27
+ // We should never get here ever.
28
+ return {
29
+ status: "error",
30
+ error: "Loaded result has invalid state where data and error are missing",
31
+ };
32
+ }
33
+
34
+ return {
35
+ status: "error",
36
+ error,
37
+ };
38
+ };
package/src/util/types.js CHANGED
@@ -1,16 +1,19 @@
1
1
  // @flow
2
2
  export type ValidData = string | boolean | number | {...};
3
3
 
4
+ export type Status = "loading" | "success" | "error";
5
+
4
6
  export type Result<TData: ValidData> =
5
7
  | {|
6
- loading: true,
7
- data?: void,
8
- error?: void,
8
+ status: "loading",
9
9
  |}
10
10
  | {|
11
- loading: false,
11
+ status: "success",
12
12
  data?: TData,
13
- error?: string,
13
+ |}
14
+ | {|
15
+ status: "error",
16
+ error: string,
14
17
  |};
15
18
 
16
19
  export type CacheEntry<TData: ValidData> =
@@ -28,24 +31,12 @@ type HandlerSubcache = {
28
31
  ...
29
32
  };
30
33
 
31
- export type InterceptCacheFn<TOptions, TData: ValidData> = (
32
- options: TOptions,
33
- cacheEntry: ?$ReadOnly<CacheEntry<TData>>,
34
- ) => ?$ReadOnly<CacheEntry<TData>>;
35
-
36
34
  export type InterceptFulfillRequestFn<TOptions, TData: ValidData> = (
37
35
  options: TOptions,
38
36
  ) => ?Promise<TData>;
39
37
 
40
- export type InterceptShouldRefreshCacheFn<TOptions, TData: ValidData> = (
41
- options: TOptions,
42
- cachedEntry: ?$ReadOnly<CacheEntry<TData>>,
43
- ) => ?boolean;
44
-
45
38
  export type Interceptor = {|
46
- getEntry?: ?InterceptCacheFn<any, any>,
47
- fulfillRequest?: ?InterceptFulfillRequestFn<any, any>,
48
- shouldRefreshCache?: ?InterceptShouldRefreshCacheFn<any, any>,
39
+ fulfillRequest: InterceptFulfillRequestFn<any, any>,
49
40
  |};
50
41
 
51
42
  export type InterceptContextData = {
@@ -120,31 +111,19 @@ export interface IRequestHandler<TOptions, TData: ValidData> {
120
111
  */
121
112
  get type(): string;
122
113
 
123
- /**
124
- * A custom cache to use with data that this handler requests.
125
- * This only affects client-side caching of data.
126
- */
127
- get cache(): ?ICache<TOptions, TData>;
128
-
129
114
  /**
130
115
  * When true, server-side results are cached and hydrated in the client.
131
116
  * When false, the server-side cache is not used and results are not
132
117
  * hydrated.
133
118
  * This should only be set to false if something is ensuring that the
134
119
  * hydrated client result will match the server result.
135
- */
136
- get hydrate(): boolean;
137
-
138
- /**
139
- * Determine if the cached data should be refreshed.
140
120
  *
141
- * If this returns true, the framework will fulfill a new request by
142
- * calling `fulfillRequest`.
121
+ * For example, if Apollo is used to handle GraphQL requests in SSR mode,
122
+ * it has its own cache that is used to hydrate the client. Setting this
123
+ * to false makes sure we don't store the data twice, which would
124
+ * unnecessarily bloat the data sent back to the client.
143
125
  */
144
- shouldRefreshCache(
145
- options: TOptions,
146
- cachedEntry: ?$ReadOnly<CacheEntry<TData>>,
147
- ): boolean;
126
+ get hydrate(): boolean;
148
127
 
149
128
  /**
150
129
  * Get the key to use for a given request. This should be idempotent for a
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2018 Khan Academy
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,124 +0,0 @@
1
- // @flow
2
- import * as React from "react";
3
- import {mount} from "enzyme";
4
-
5
- import InterceptContext from "../intercept-context.js";
6
- import InterceptData from "../intercept-data.js";
7
- import InterceptCache from "../intercept-cache.js";
8
-
9
- import type {IRequestHandler} from "../../util/types.js";
10
-
11
- describe("InterceptCache", () => {
12
- afterEach(() => {
13
- jest.resetAllMocks();
14
- });
15
-
16
- it("should update context with getEntry", () => {
17
- // Arrange
18
- const fakeHandler: IRequestHandler<string, string> = {
19
- fulfillRequest: () => Promise.resolve("data"),
20
- getKey: (o) => o,
21
- shouldRefreshCache: () => false,
22
- type: "MY_HANDLER",
23
- cache: null,
24
- hydrate: true,
25
- };
26
- const getEntryFn = jest.fn();
27
- const captureContextFn = jest.fn();
28
-
29
- // Act
30
- mount(
31
- <InterceptCache handler={fakeHandler} getEntry={getEntryFn}>
32
- <InterceptContext.Consumer>
33
- {captureContextFn}
34
- </InterceptContext.Consumer>
35
- </InterceptCache>,
36
- );
37
-
38
- // Assert
39
- expect(captureContextFn).toHaveBeenCalledWith(
40
- expect.objectContaining({
41
- MY_HANDLER: {
42
- getEntry: getEntryFn,
43
- },
44
- }),
45
- );
46
- });
47
-
48
- it("should override parent InterceptData", () => {
49
- // Arrange
50
- const fakeHandler: IRequestHandler<string, string> = {
51
- fulfillRequest: () => Promise.resolve("data"),
52
- getKey: (o) => o,
53
- shouldRefreshCache: () => false,
54
- type: "MY_HANDLER",
55
- cache: null,
56
- hydrate: true,
57
- };
58
- const getEntry1Fn = jest.fn();
59
- const getEntry2Fn = jest.fn();
60
- const captureContextFn = jest.fn();
61
-
62
- // Act
63
- mount(
64
- <InterceptCache handler={fakeHandler} getEntry={getEntry1Fn}>
65
- <InterceptCache handler={fakeHandler} getEntry={getEntry2Fn}>
66
- <InterceptContext.Consumer>
67
- {captureContextFn}
68
- </InterceptContext.Consumer>
69
- </InterceptCache>
70
- </InterceptCache>,
71
- );
72
-
73
- // Assert
74
- expect(captureContextFn).toHaveBeenCalledWith(
75
- expect.objectContaining({
76
- MY_HANDLER: {
77
- getEntry: getEntry2Fn,
78
- },
79
- }),
80
- );
81
- });
82
-
83
- it("should not change InterceptData methods on existing interceptor", () => {
84
- // Arrange
85
- const fakeHandler: IRequestHandler<string, string> = {
86
- fulfillRequest: () => Promise.resolve("data"),
87
- getKey: (o) => o,
88
- shouldRefreshCache: () => false,
89
- type: "MY_HANDLER",
90
- cache: null,
91
- hydrate: true,
92
- };
93
- const fulfillRequestFn = jest.fn();
94
- const shouldRefreshCacheFn = jest.fn();
95
- const getEntryFn = jest.fn();
96
- const captureContextFn = jest.fn();
97
-
98
- // Act
99
- mount(
100
- <InterceptData
101
- handler={fakeHandler}
102
- fulfillRequest={fulfillRequestFn}
103
- shouldRefreshCache={shouldRefreshCacheFn}
104
- >
105
- <InterceptCache handler={fakeHandler} getEntry={getEntryFn}>
106
- <InterceptContext.Consumer>
107
- {captureContextFn}
108
- </InterceptContext.Consumer>
109
- </InterceptCache>
110
- </InterceptData>,
111
- );
112
-
113
- // Assert
114
- expect(captureContextFn).toHaveBeenCalledWith(
115
- expect.objectContaining({
116
- MY_HANDLER: {
117
- fulfillRequest: fulfillRequestFn,
118
- shouldRefreshCache: shouldRefreshCacheFn,
119
- getEntry: getEntryFn,
120
- },
121
- }),
122
- );
123
- });
124
- });