@khanacademy/wonder-blocks-data 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/es/index.js +356 -332
  3. package/dist/index.js +507 -456
  4. package/docs.md +17 -35
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +7 -46
  7. package/src/__tests__/generated-snapshot.test.js +56 -122
  8. package/src/components/__tests__/data.test.js +372 -297
  9. package/src/components/__tests__/intercept-data.test.js +6 -30
  10. package/src/components/data.js +153 -21
  11. package/src/components/data.md +38 -69
  12. package/src/components/intercept-context.js +6 -2
  13. package/src/components/intercept-data.js +40 -51
  14. package/src/components/intercept-data.md +13 -27
  15. package/src/components/track-data.md +9 -23
  16. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +17 -0
  17. package/src/hooks/__tests__/use-server-effect.test.js +217 -0
  18. package/src/hooks/__tests__/use-shared-cache.test.js +307 -0
  19. package/src/hooks/use-server-effect.js +45 -0
  20. package/src/hooks/use-shared-cache.js +106 -0
  21. package/src/index.js +15 -19
  22. package/src/util/__tests__/__snapshots__/scoped-in-memory-cache.test.js.snap +19 -0
  23. package/src/util/__tests__/request-fulfillment.test.js +42 -85
  24. package/src/util/__tests__/request-tracking.test.js +72 -191
  25. package/src/util/__tests__/{result-from-cache-entry.test.js → result-from-cache-response.test.js} +9 -10
  26. package/src/util/__tests__/scoped-in-memory-cache.test.js +396 -0
  27. package/src/util/__tests__/ssr-cache.test.js +639 -0
  28. package/src/util/request-fulfillment.js +36 -44
  29. package/src/util/request-tracking.js +62 -75
  30. package/src/util/{result-from-cache-entry.js → result-from-cache-response.js} +10 -13
  31. package/src/util/scoped-in-memory-cache.js +149 -0
  32. package/src/util/ssr-cache.js +206 -0
  33. package/src/util/types.js +43 -108
  34. package/src/hooks/__tests__/use-data.test.js +0 -826
  35. package/src/hooks/use-data.js +0 -143
  36. package/src/util/__tests__/memory-cache.test.js +0 -446
  37. package/src/util/__tests__/request-handler.test.js +0 -121
  38. package/src/util/__tests__/response-cache.test.js +0 -879
  39. package/src/util/memory-cache.js +0 -187
  40. package/src/util/request-handler.js +0 -42
  41. package/src/util/request-handler.md +0 -51
  42. package/src/util/response-cache.js +0 -213
@@ -1,15 +1,10 @@
1
1
  // @flow
2
- import {ResponseCache} from "./response-cache.js";
2
+ import {SsrCache} from "./ssr-cache.js";
3
3
 
4
- import type {ValidData, CacheEntry, IRequestHandler} from "./types.js";
5
-
6
- type Subcache = {
7
- [key: string]: Promise<any>,
8
- ...
9
- };
4
+ import type {ValidCacheData, CachedResponse} from "./types.js";
10
5
 
11
6
  type RequestCache = {
12
- [handlerType: string]: Subcache,
7
+ [id: string]: Promise<any>,
13
8
  ...
14
9
  };
15
10
 
@@ -23,44 +18,39 @@ export class RequestFulfillment {
23
18
  return _default;
24
19
  }
25
20
 
26
- _responseCache: ResponseCache;
21
+ _responseCache: SsrCache;
27
22
  _requests: RequestCache = {};
28
23
 
29
- constructor(responseCache: ?ResponseCache = undefined) {
30
- this._responseCache = responseCache || ResponseCache.Default;
24
+ constructor(responseCache: ?SsrCache = undefined) {
25
+ this._responseCache = responseCache || SsrCache.Default;
31
26
  }
32
27
 
33
- _getHandlerSubcache: <TOptions, TData: ValidData>(
34
- handler: IRequestHandler<TOptions, TData>,
35
- ) => Subcache = <TOptions, TData: ValidData>(
36
- handler: IRequestHandler<TOptions, TData>,
37
- ): Subcache => {
38
- if (!this._requests[handler.type]) {
39
- this._requests[handler.type] = {};
40
- }
41
- return this._requests[handler.type];
42
- };
43
-
44
28
  /**
45
29
  * Get a promise of a request for a given handler and options.
46
30
  *
47
31
  * This will return an inflight request if one exists, otherwise it will
48
32
  * make a new request. Inflight requests are deleted once they resolve.
49
33
  */
50
- fulfill: <TOptions, TData: ValidData>(
51
- handler: IRequestHandler<TOptions, TData>,
52
- options: TOptions,
53
- ) => Promise<CacheEntry<TData>> = <TOptions, TData: ValidData>(
54
- handler: IRequestHandler<TOptions, TData>,
55
- options: TOptions,
56
- ): Promise<CacheEntry<TData>> => {
57
- const handlerRequests = this._getHandlerSubcache(handler);
58
- const key = handler.getKey(options);
59
-
34
+ fulfill: <TData: ValidCacheData>(
35
+ id: string,
36
+ options: {|
37
+ handler: () => Promise<?TData>,
38
+ hydrate?: boolean,
39
+ |},
40
+ ) => Promise<?CachedResponse<TData>> = <TData: ValidCacheData>(
41
+ id: string,
42
+ {
43
+ handler,
44
+ hydrate = true,
45
+ }: {|
46
+ handler: () => Promise<?TData>,
47
+ hydrate?: boolean,
48
+ |},
49
+ ): Promise<?CachedResponse<TData>> => {
60
50
  /**
61
51
  * If we have an inflight request, we'll provide that.
62
52
  */
63
- const inflight = handlerRequests[key];
53
+ const inflight = this._requests[id];
64
54
  if (inflight) {
65
55
  return inflight;
66
56
  }
@@ -70,36 +60,38 @@ export class RequestFulfillment {
70
60
  */
71
61
  const {cacheData, cacheError} = this._responseCache;
72
62
  try {
73
- const request = handler
74
- .fulfillRequest(options)
75
- .then((data: TData) => {
76
- delete handlerRequests[key];
63
+ const request = handler()
64
+ .then((data: ?TData) => {
65
+ delete this._requests[id];
66
+ if (data == null) {
67
+ // Request aborted. We won't cache this.
68
+ return null;
69
+ }
70
+
77
71
  /**
78
72
  * Let's cache the data!
79
73
  *
80
74
  * NOTE: This only caches when we're server side.
81
75
  */
82
- return cacheData<TOptions, TData>(handler, options, data);
76
+ return cacheData<TData>(id, data, hydrate);
83
77
  })
84
78
  .catch((error: string | Error) => {
85
- delete handlerRequests[key];
79
+ delete this._requests[id];
86
80
  /**
87
81
  * Let's cache the error!
88
82
  *
89
83
  * NOTE: This only caches when we're server side.
90
84
  */
91
- return cacheError<TOptions, TData>(handler, options, error);
85
+ return cacheError<TData>(id, error, hydrate);
92
86
  });
93
- handlerRequests[key] = request;
87
+ this._requests[id] = request;
94
88
  return request;
95
89
  } catch (e) {
96
90
  /**
97
91
  * In this case, we don't cache an inflight request, because there
98
92
  * really isn't one.
99
93
  */
100
- return Promise.resolve(
101
- cacheError<TOptions, TData>(handler, options, e),
102
- );
94
+ return Promise.resolve(cacheError<TData>(id, e, hydrate));
103
95
  }
104
96
  };
105
97
  }
@@ -1,22 +1,21 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
- import {ResponseCache} from "./response-cache.js";
3
+ import {SsrCache} from "./ssr-cache.js";
4
4
  import {RequestFulfillment} from "./request-fulfillment.js";
5
5
 
6
- import type {Cache, IRequestHandler} from "./types.js";
6
+ import type {ResponseCache, ValidCacheData} from "./types.js";
7
7
 
8
- type TrackerFn = (handler: IRequestHandler<any, any>, options: any) => void;
9
-
10
- type HandlerCache = {
11
- [type: string]: IRequestHandler<any, any>,
12
- ...
13
- };
8
+ type TrackerFn = <TData: ValidCacheData>(
9
+ id: string,
10
+ handler: () => Promise<?TData>,
11
+ hydrate: boolean,
12
+ ) => void;
14
13
 
15
14
  type RequestCache = {
16
- [handlerType: string]: {
17
- [key: string]: any,
18
- ...
19
- },
15
+ [id: string]: {|
16
+ hydrate?: boolean,
17
+ handler: () => Promise<?any>,
18
+ |},
20
19
  ...
21
20
  };
22
21
 
@@ -50,13 +49,12 @@ export class RequestTracker {
50
49
  /**
51
50
  * These are the caches for tracked requests, their handlers, and responses.
52
51
  */
53
- _trackedHandlers: HandlerCache = {};
54
52
  _trackedRequests: RequestCache = {};
55
- _responseCache: ResponseCache;
53
+ _responseCache: SsrCache;
56
54
  _requestFulfillment: RequestFulfillment;
57
55
 
58
- constructor(responseCache: ?ResponseCache = undefined) {
59
- this._responseCache = responseCache || ResponseCache.Default;
56
+ constructor(responseCache: ?SsrCache = undefined) {
57
+ this._responseCache = responseCache || SsrCache.Default;
60
58
  this._requestFulfillment = new RequestFulfillment(responseCache);
61
59
  }
62
60
 
@@ -66,26 +64,23 @@ export class RequestTracker {
66
64
  * This method caches a request and its handler for use during server-side
67
65
  * rendering to allow us to fulfill requests before producing a final render.
68
66
  */
69
- trackDataRequest: (
70
- handler: IRequestHandler<any, any>,
71
- options: any,
72
- ) => void = (handler: IRequestHandler<any, any>, options: any): void => {
73
- const key = handler.getKey(options);
74
- const type = handler.type;
75
-
76
- /**
77
- * Make sure we have stored the handler for use when fulfilling requests.
78
- */
79
- if (this._trackedHandlers[type] == null) {
80
- this._trackedHandlers[type] = handler;
81
- this._trackedRequests[type] = {};
82
- }
83
-
67
+ trackDataRequest: <TData: ValidCacheData>(
68
+ id: string,
69
+ handler: () => Promise<?TData>,
70
+ hydrate: boolean,
71
+ ) => void = <TData: ValidCacheData>(
72
+ id: string,
73
+ handler: () => Promise<?TData>,
74
+ hydrate: boolean,
75
+ ): void => {
84
76
  /**
85
77
  * If we don't already have this tracked, then let's track it.
86
78
  */
87
- if (this._trackedRequests[type][key] == null) {
88
- this._trackedRequests[type][key] = options;
79
+ if (this._trackedRequests[id] == null) {
80
+ this._trackedRequests[id] = {
81
+ handler,
82
+ hydrate,
83
+ };
89
84
  }
90
85
  };
91
86
 
@@ -93,7 +88,6 @@ export class RequestTracker {
93
88
  * Reset our tracking info.
94
89
  */
95
90
  reset: () => void = () => {
96
- this._trackedHandlers = {};
97
91
  this._trackedRequests = {};
98
92
  };
99
93
 
@@ -114,52 +108,45 @@ export class RequestTracker {
114
108
  * Calling this method marks tracked requests as fulfilled; requests are
115
109
  * removed from the list of tracked requests by calling this method.
116
110
  *
117
- * @returns {Promise<Cache>} A frozen cache of the data that was cached
118
- * as a result of fulfilling the tracked requests.
111
+ * @returns {Promise<ResponseCache>} The promise of the data that was
112
+ * cached as a result of fulfilling the tracked requests.
119
113
  */
120
- fulfillTrackedRequests: () => Promise<$ReadOnly<Cache>> = (): Promise<
121
- $ReadOnly<Cache>,
122
- > => {
123
- const promises = [];
114
+ fulfillTrackedRequests: () => Promise<ResponseCache> =
115
+ (): Promise<ResponseCache> => {
116
+ const promises = [];
124
117
 
125
- for (const handlerType of Object.keys(this._trackedHandlers)) {
126
- const handler = this._trackedHandlers[handlerType];
127
-
128
- // For each handler, we will perform the request fulfillments!
129
- const requests = this._trackedRequests[handlerType];
130
- for (const requestKey of Object.keys(requests)) {
118
+ for (const requestKey of Object.keys(this._trackedRequests)) {
131
119
  const promise = this._requestFulfillment.fulfill(
132
- handler,
133
- requests[requestKey],
120
+ requestKey,
121
+ this._trackedRequests[requestKey],
134
122
  );
135
123
  promises.push(promise);
136
124
  }
137
- }
138
-
139
- /**
140
- * Clear out our tracked info.
141
- *
142
- * We call this now for a simpler API.
143
- *
144
- * If we reset the tracked calls after all promises resolve, any
145
- * requst tracking done while promises are in flight would be lost.
146
- *
147
- * If we don't reset at all, then we have to expose the `reset` call
148
- * for consumers to use, or they'll only ever be able to accumulate
149
- * more and more tracked requests, having to fulfill them all every
150
- * time.
151
- *
152
- * Calling it here means we can have multiple "track -> request" cycles
153
- * in a row and in an easy to reason about manner.
154
- *
155
- */
156
- this.reset();
157
125
 
158
- /**
159
- * Let's wait for everything to fulfill, and then clone the cached data.
160
- */
161
- return Promise.all(promises).then(() =>
162
- this._responseCache.cloneHydratableData(),
163
- );
164
- };
126
+ /**
127
+ * Clear out our tracked info.
128
+ *
129
+ * We call this now for a simpler API.
130
+ *
131
+ * If we reset the tracked calls after all promises resolve, any
132
+ * requst tracking done while promises are in flight would be lost.
133
+ *
134
+ * If we don't reset at all, then we have to expose the `reset` call
135
+ * for consumers to use, or they'll only ever be able to accumulate
136
+ * more and more tracked requests, having to fulfill them all every
137
+ * time.
138
+ *
139
+ * Calling it here means we can have multiple "track -> request" cycles
140
+ * in a row and in an easy to reason about manner.
141
+ *
142
+ */
143
+ this.reset();
144
+
145
+ /**
146
+ * Let's wait for everything to fulfill, and then clone the cached data.
147
+ */
148
+ return Promise.all(promises).then(() =>
149
+ this._responseCache.cloneHydratableData(),
150
+ );
151
+ };
165
152
  }
@@ -1,11 +1,11 @@
1
1
  // @flow
2
- import type {ValidData, CacheEntry, Result} from "./types.js";
2
+ import type {ValidCacheData, CachedResponse, Result} from "./types.js";
3
3
 
4
4
  /**
5
5
  * Turns a cache entry into a stateful result.
6
6
  */
7
- export const resultFromCacheEntry = <TData: ValidData>(
8
- cacheEntry: ?CacheEntry<TData>,
7
+ export const resultFromCachedResponse = <TData: ValidCacheData>(
8
+ cacheEntry: ?CachedResponse<TData>,
9
9
  ): Result<TData> => {
10
10
  // No cache entry means we didn't load one yet.
11
11
  if (cacheEntry == null) {
@@ -15,24 +15,21 @@ export const resultFromCacheEntry = <TData: ValidData>(
15
15
  }
16
16
 
17
17
  const {data, error} = cacheEntry;
18
-
19
- if (data != null) {
18
+ if (error != null) {
20
19
  return {
21
- status: "success",
22
- data,
20
+ status: "error",
21
+ error,
23
22
  };
24
23
  }
25
24
 
26
- if (error == null) {
27
- // We should never get here ever.
25
+ if (data != null) {
28
26
  return {
29
- status: "error",
30
- error: "Loaded result has invalid state where data and error are missing",
27
+ status: "success",
28
+ data,
31
29
  };
32
30
  }
33
31
 
34
32
  return {
35
- status: "error",
36
- error,
33
+ status: "aborted",
37
34
  };
38
35
  };
@@ -0,0 +1,149 @@
1
+ // @flow
2
+ import {KindError, Errors, clone} from "@khanacademy/wonder-stuff-core";
3
+ import type {ValidCacheData, ScopedCache} from "./types.js";
4
+
5
+ /**
6
+ * Describe an in-memory cache.
7
+ */
8
+ export class ScopedInMemoryCache {
9
+ _cache: ScopedCache;
10
+
11
+ constructor(initialCache: ScopedCache = Object.freeze({})) {
12
+ try {
13
+ this._cache = clone(initialCache);
14
+ } catch (e) {
15
+ throw new KindError(
16
+ `An error occurred trying to initialize from a response cache snapshot: ${e}`,
17
+ Errors.InvalidInput,
18
+ );
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Indicate if this cache is being used or not.
24
+ *
25
+ * When the cache has entries, returns `true`; otherwise, returns `false`.
26
+ */
27
+ get inUse(): boolean {
28
+ return Object.keys(this._cache).length > 0;
29
+ }
30
+
31
+ /**
32
+ * Set a value in the cache.
33
+ */
34
+ set: <TValue: ValidCacheData>(
35
+ scope: string,
36
+ id: string,
37
+ value: TValue,
38
+ ) => void = <TValue: ValidCacheData>(scope, id, value: TValue): void => {
39
+ if (!id || typeof id !== "string") {
40
+ throw new KindError(
41
+ "id must be non-empty string",
42
+ Errors.InvalidInput,
43
+ );
44
+ }
45
+
46
+ if (!scope || typeof scope !== "string") {
47
+ throw new KindError(
48
+ "scope must be non-empty string",
49
+ Errors.InvalidInput,
50
+ );
51
+ }
52
+
53
+ if (typeof value === "function") {
54
+ throw new KindError(
55
+ "value must be a non-function value",
56
+ Errors.InvalidInput,
57
+ );
58
+ }
59
+
60
+ this._cache[scope] = this._cache[scope] ?? {};
61
+ this._cache[scope][id] = Object.freeze(clone(value));
62
+ };
63
+
64
+ /**
65
+ * Retrieve a value from the cache.
66
+ */
67
+ get: (scope: string, id: string) => ?ValidCacheData = (
68
+ scope,
69
+ id,
70
+ ): ?ValidCacheData => {
71
+ return this._cache[scope]?.[id] ?? null;
72
+ };
73
+
74
+ /**
75
+ * Purge an item from the cache.
76
+ */
77
+ purge: (scope: string, id: string) => void = (scope, id) => {
78
+ if (!this._cache[scope]?.[id]) {
79
+ return;
80
+ }
81
+ delete this._cache[scope][id];
82
+ if (Object.keys(this._cache[scope]).length === 0) {
83
+ delete this._cache[scope];
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Purge a scope of items that match the given predicate.
89
+ *
90
+ * If the predicate is omitted, then all items in the scope are purged.
91
+ */
92
+ purgeScope: (
93
+ scope: string,
94
+ predicate?: (id: string, value: ValidCacheData) => boolean,
95
+ ) => void = (scope, predicate) => {
96
+ if (!this._cache[scope]) {
97
+ return;
98
+ }
99
+
100
+ if (predicate == null) {
101
+ delete this._cache[scope];
102
+ return;
103
+ }
104
+
105
+ for (const key of Object.keys(this._cache[scope])) {
106
+ if (predicate(key, this._cache[scope][key])) {
107
+ delete this._cache[scope][key];
108
+ }
109
+ }
110
+ if (Object.keys(this._cache[scope]).length === 0) {
111
+ delete this._cache[scope];
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Purge all items from the cache that match the given predicate.
117
+ *
118
+ * If the predicate is omitted, then all items in the cache are purged.
119
+ */
120
+ purgeAll: (
121
+ predicate?: (
122
+ scope: string,
123
+ id: string,
124
+ value: ValidCacheData,
125
+ ) => boolean,
126
+ ) => void = (predicate) => {
127
+ if (predicate == null) {
128
+ this._cache = {};
129
+ return;
130
+ }
131
+
132
+ for (const scope of Object.keys(this._cache)) {
133
+ this.purgeScope(scope, (id, value) => predicate(scope, id, value));
134
+ }
135
+ };
136
+
137
+ /**
138
+ * Clone the cache.
139
+ */
140
+ clone: () => ScopedCache = () => {
141
+ try {
142
+ return clone(this._cache);
143
+ } catch (e) {
144
+ throw new Error(
145
+ `An error occurred while trying to clone the cache: ${e}`,
146
+ );
147
+ }
148
+ };
149
+ }