@khanacademy/wonder-blocks-data 3.1.1 → 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 (46) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/es/index.js +375 -335
  3. package/dist/index.js +527 -461
  4. package/docs.md +17 -35
  5. package/package.json +3 -3
  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/gql-router.js +1 -1
  13. package/src/components/intercept-context.js +6 -2
  14. package/src/components/intercept-data.js +40 -51
  15. package/src/components/intercept-data.md +13 -27
  16. package/src/components/track-data.md +9 -23
  17. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +17 -0
  18. package/src/hooks/__tests__/use-gql.test.js +1 -0
  19. package/src/hooks/__tests__/use-server-effect.test.js +217 -0
  20. package/src/hooks/__tests__/use-shared-cache.test.js +307 -0
  21. package/src/hooks/use-gql.js +39 -31
  22. package/src/hooks/use-server-effect.js +45 -0
  23. package/src/hooks/use-shared-cache.js +106 -0
  24. package/src/index.js +15 -19
  25. package/src/util/__tests__/__snapshots__/scoped-in-memory-cache.test.js.snap +19 -0
  26. package/src/util/__tests__/request-fulfillment.test.js +42 -85
  27. package/src/util/__tests__/request-tracking.test.js +72 -191
  28. package/src/util/__tests__/{result-from-cache-entry.test.js → result-from-cache-response.test.js} +9 -10
  29. package/src/util/__tests__/scoped-in-memory-cache.test.js +396 -0
  30. package/src/util/__tests__/ssr-cache.test.js +639 -0
  31. package/src/util/gql-types.js +5 -10
  32. package/src/util/request-fulfillment.js +36 -44
  33. package/src/util/request-tracking.js +62 -75
  34. package/src/util/{result-from-cache-entry.js → result-from-cache-response.js} +10 -13
  35. package/src/util/scoped-in-memory-cache.js +149 -0
  36. package/src/util/ssr-cache.js +206 -0
  37. package/src/util/types.js +43 -108
  38. package/src/hooks/__tests__/use-data.test.js +0 -826
  39. package/src/hooks/use-data.js +0 -143
  40. package/src/util/__tests__/memory-cache.test.js +0 -446
  41. package/src/util/__tests__/request-handler.test.js +0 -121
  42. package/src/util/__tests__/response-cache.test.js +0 -879
  43. package/src/util/memory-cache.js +0 -187
  44. package/src/util/request-handler.js +0 -42
  45. package/src/util/request-handler.md +0 -51
  46. package/src/util/response-cache.js +0 -213
@@ -0,0 +1,206 @@
1
+ // @flow
2
+ import {Server} from "@khanacademy/wonder-blocks-core";
3
+ import {ScopedInMemoryCache} from "./scoped-in-memory-cache.js";
4
+
5
+ import type {ValidCacheData, CachedResponse, ResponseCache} from "./types.js";
6
+
7
+ const DefaultScope = "default";
8
+
9
+ /**
10
+ * The default instance is stored here.
11
+ * It's created below in the Default() static property.
12
+ */
13
+ let _default: SsrCache;
14
+
15
+ /**
16
+ * Implements the response cache.
17
+ *
18
+ * INTERNAL USE ONLY
19
+ */
20
+ export class SsrCache {
21
+ static get Default(): SsrCache {
22
+ if (!_default) {
23
+ _default = new SsrCache();
24
+ }
25
+ return _default;
26
+ }
27
+
28
+ _hydrationCache: ScopedInMemoryCache;
29
+ _ssrOnlyCache: ?ScopedInMemoryCache;
30
+
31
+ constructor(
32
+ hydrationCache: ?ScopedInMemoryCache = null,
33
+ ssrOnlyCache: ?ScopedInMemoryCache = null,
34
+ ) {
35
+ this._ssrOnlyCache = Server.isServerSide()
36
+ ? ssrOnlyCache || new ScopedInMemoryCache()
37
+ : undefined;
38
+ this._hydrationCache = hydrationCache || new ScopedInMemoryCache();
39
+ }
40
+
41
+ _setCachedResponse<TData: ValidCacheData>(
42
+ id: string,
43
+ entry: CachedResponse<TData>,
44
+ hydrate: boolean,
45
+ ): CachedResponse<TData> {
46
+ const frozenEntry = Object.freeze(entry);
47
+ if (Server.isServerSide()) {
48
+ // We are server-side.
49
+ // We need to store this value.
50
+ if (hydrate) {
51
+ this._hydrationCache.set(DefaultScope, id, frozenEntry);
52
+ } else {
53
+ // Usually, when server-side, this cache will always be present.
54
+ // We do fake server-side in our doc example though, when it
55
+ // won't be.
56
+ this._ssrOnlyCache?.set(DefaultScope, id, frozenEntry);
57
+ }
58
+ }
59
+ return frozenEntry;
60
+ }
61
+
62
+ /**
63
+ * Initialize the cache from a given cache state.
64
+ *
65
+ * This can only be called if the cache is not already in use.
66
+ */
67
+ initialize: (source: ResponseCache) => void = (source) => {
68
+ if (this._hydrationCache.inUse) {
69
+ throw new Error(
70
+ "Cannot initialize data response cache more than once",
71
+ );
72
+ }
73
+ this._hydrationCache = new ScopedInMemoryCache({
74
+ // $FlowIgnore[incompatible-call]
75
+ [DefaultScope]: source,
76
+ });
77
+ };
78
+
79
+ /**
80
+ * Cache data for a specific response.
81
+ *
82
+ * This is a noop when client-side.
83
+ */
84
+ cacheData: <TData: ValidCacheData>(
85
+ id: string,
86
+ data: TData,
87
+ hydrate: boolean,
88
+ ) => CachedResponse<TData> = <TData: ValidCacheData>(
89
+ id: string,
90
+ data: TData,
91
+ hydrate: boolean,
92
+ ): CachedResponse<TData> => this._setCachedResponse(id, {data}, hydrate);
93
+
94
+ /**
95
+ * Cache an error for a specific response.
96
+ *
97
+ * This is a noop when client-side.
98
+ */
99
+ cacheError: <TData: ValidCacheData>(
100
+ id: string,
101
+ error: Error | string,
102
+ hydrate: boolean,
103
+ ) => CachedResponse<TData> = <TData: ValidCacheData>(
104
+ id: string,
105
+ error: Error | string,
106
+ hydrate: boolean,
107
+ ): CachedResponse<TData> => {
108
+ const errorMessage = typeof error === "string" ? error : error.message;
109
+ return this._setCachedResponse(id, {error: errorMessage}, hydrate);
110
+ };
111
+
112
+ /**
113
+ * Retrieve data from our cache.
114
+ */
115
+ getEntry: <TData: ValidCacheData>(
116
+ id: string,
117
+ ) => ?$ReadOnly<CachedResponse<TData>> = <TData: ValidCacheData>(
118
+ id: string,
119
+ ): ?$ReadOnly<CachedResponse<TData>> => {
120
+ // Get the cached entry for this value.
121
+
122
+ // We first look in the ssr cache and then the hydration cache.
123
+ const internalEntry =
124
+ this._ssrOnlyCache?.get(DefaultScope, id) ??
125
+ this._hydrationCache.get(DefaultScope, id);
126
+
127
+ // If we are not server-side and we hydrated something, let's clear
128
+ // that from the hydration cache to save memory.
129
+ if (this._ssrOnlyCache == null && internalEntry != null) {
130
+ // We now delete this from our hydration cache as we don't need it.
131
+ // This does mean that if another handler of the same type but
132
+ // without some sort of linked cache won't get the value, but
133
+ // that's not an expected use-case. If two different places use the
134
+ // same handler and options (i.e. the same request), then the
135
+ // handler should cater to that to ensure they share the result.
136
+ this._hydrationCache.purge(DefaultScope, id);
137
+ }
138
+ // Getting the typing right between the in-memory cache and this
139
+ // is hard. Just telling flow it's OK.
140
+ // $FlowIgnore[incompatible-return]
141
+ return internalEntry;
142
+ };
143
+
144
+ /**
145
+ * Remove from cache, the entry matching the given handler and options.
146
+ *
147
+ * This will, if present therein, remove the value from the custom cache
148
+ * associated with the handler and the framework in-memory cache.
149
+ *
150
+ * Returns true if something was removed from any cache; otherwise, false.
151
+ */
152
+ remove: (id: string) => boolean = (id: string): boolean => {
153
+ // NOTE(somewhatabstract): We could invoke removeAll with a predicate
154
+ // to match the key of the entry we're removing, but that's an
155
+ // inefficient way to remove a single item, so let's not do that.
156
+
157
+ // Delete the entry from the appropriate cache.
158
+ return (
159
+ this._hydrationCache.purge(DefaultScope, id) ||
160
+ (this._ssrOnlyCache?.purge(DefaultScope, id) ?? false)
161
+ );
162
+ };
163
+
164
+ /**
165
+ * Remove from cache, any entries matching the given handler and predicate.
166
+ *
167
+ * This will, if present therein, remove matching values from the framework
168
+ * in-memory cache.
169
+ *
170
+ * It returns a count of all records removed.
171
+ */
172
+ removeAll: (
173
+ predicate?: (
174
+ key: string,
175
+ cachedEntry: $ReadOnly<CachedResponse<ValidCacheData>>,
176
+ ) => boolean,
177
+ ) => void = (predicate) => {
178
+ const realPredicate = predicate
179
+ ? // We know what we're putting into the cache so let's assume it
180
+ // conforms.
181
+ // $FlowIgnore[incompatible-call]
182
+ (_, key, cachedEntry) => predicate(key, cachedEntry)
183
+ : undefined;
184
+
185
+ // Apply the predicate to what we have in our caches.
186
+ this._hydrationCache.purgeAll(realPredicate);
187
+ this._ssrOnlyCache?.purgeAll(realPredicate);
188
+ };
189
+
190
+ /**
191
+ * Deep clone the hydration cache.
192
+ *
193
+ * By design, this only clones the data that is to be used for hydration.
194
+ */
195
+ cloneHydratableData: () => ResponseCache = (): ResponseCache => {
196
+ // We return our hydration cache only.
197
+ const cache = this._hydrationCache.clone();
198
+
199
+ // If we're empty, we still want to return an object, so we default
200
+ // to an empty object.
201
+ // We only need the default scope out of our scoped in-memory cache.
202
+ // We know that it conforms to our expectations.
203
+ // $FlowIgnore[incompatible-return]
204
+ return cache[DefaultScope] ?? {};
205
+ };
206
+ }
package/src/util/types.js CHANGED
@@ -1,135 +1,70 @@
1
1
  // @flow
2
- export type ValidData = string | boolean | number | {...};
3
-
4
- export type Status = "loading" | "success" | "error";
2
+ /**
3
+ * Define what can be cached.
4
+ *
5
+ * We disallow functions and undefined as undefined represents a cache miss
6
+ * and functions are not allowed.
7
+ */
8
+ export type ValidCacheData =
9
+ | string
10
+ | boolean
11
+ | number
12
+ | {...}
13
+ | Array<?ValidCacheData>;
5
14
 
6
- export type Result<TData: ValidData> =
15
+ /**
16
+ * The normalized result of a request.
17
+ */
18
+ export type Result<TData: ValidCacheData> =
7
19
  | {|
8
20
  status: "loading",
9
21
  |}
10
22
  | {|
11
23
  status: "success",
12
- data?: TData,
24
+ data: TData,
13
25
  |}
14
26
  | {|
15
27
  status: "error",
16
28
  error: string,
29
+ |}
30
+ | {|
31
+ status: "aborted",
17
32
  |};
18
33
 
19
- export type CacheEntry<TData: ValidData> =
34
+ /**
35
+ * A cache entry for a fulfilled request response.
36
+ */
37
+ export type CachedResponse<TData: ValidCacheData> =
20
38
  | {|
21
39
  +error: string,
22
- +data?: ?void,
40
+ +data?: void,
23
41
  |}
24
42
  | {|
25
43
  +data: TData,
26
- +error?: ?void,
44
+ +error?: void,
27
45
  |};
28
46
 
29
- type HandlerSubcache = {
30
- [key: string]: CacheEntry<any>,
31
- ...
32
- };
33
-
34
- export type InterceptFulfillRequestFn<TOptions, TData: ValidData> = (
35
- options: TOptions,
36
- ) => ?Promise<TData>;
37
-
38
- export type Interceptor = {|
39
- fulfillRequest: InterceptFulfillRequestFn<any, any>,
40
- |};
41
-
42
- export type InterceptContextData = {
43
- [key: string]: Interceptor,
44
- ...
45
- };
46
-
47
- export type Cache = {
48
- [key: string]: HandlerSubcache,
47
+ /**
48
+ * A cache of fulfilled request responses.
49
+ */
50
+ export type ResponseCache = {
51
+ [key: string]: CachedResponse<any>,
49
52
  ...
50
53
  };
51
54
 
52
- export interface ICache<TOptions, TData: ValidData> {
53
- /**
54
- * Stores a value in the cache for the given handler and options.
55
- */
56
- store(
57
- handler: IRequestHandler<TOptions, TData>,
58
- options: TOptions,
59
- entry: CacheEntry<TData>,
60
- ): void;
61
-
62
- /**
63
- * Retrieves a value from the cache for the given handler and options.
64
- */
65
- retrieve(
66
- handler: IRequestHandler<TOptions, TData>,
67
- options: TOptions,
68
- ): ?$ReadOnly<CacheEntry<TData>>;
69
-
70
- /**
71
- * Remove the cached entry for the given handler and options.
72
- *
73
- * If the item exists in the cache, the cached entry is deleted and true
74
- * is returned. Otherwise, this returns false.
75
- */
76
- remove(
77
- handler: IRequestHandler<TOptions, TData>,
78
- options: TOptions,
79
- ): boolean;
80
-
81
- /**
82
- * Remove all cached entries for the given handler that, optionally, match
83
- * a given predicate.
84
- *
85
- * Returns the number of entries that were cleared from the cache.
86
- */
87
- removeAll(
88
- handler: IRequestHandler<TOptions, TData>,
89
- predicate?: (
90
- key: string,
91
- cachedEntry: $ReadOnly<CacheEntry<TData>>,
92
- ) => boolean,
93
- ): number;
94
- }
95
-
96
55
  /**
97
- * A handler for data requests.
56
+ * A cache with scoped sections.
98
57
  */
99
- export interface IRequestHandler<TOptions, TData: ValidData> {
58
+ export type ScopedCache = {
100
59
  /**
101
- * Fulfill a given request.
102
- *
103
- * @param {TOptions} options Options tha the request may need.
104
- * @return {Promise<TData>} A promise of the requested data.
60
+ * The cache is scoped to allow easier clearing of different types of usage.
105
61
  */
106
- fulfillRequest(options: TOptions): Promise<TData>;
107
-
108
- /**
109
- * The handler type; this is used to uniquely identify this handler from
110
- * any other handler.
111
- */
112
- get type(): string;
113
-
114
- /**
115
- * When true, server-side results are cached and hydrated in the client.
116
- * When false, the server-side cache is not used and results are not
117
- * hydrated.
118
- * This should only be set to false if something is ensuring that the
119
- * hydrated client result will match the server result.
120
- *
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.
125
- */
126
- get hydrate(): boolean;
127
-
128
- /**
129
- * Get the key to use for a given request. This should be idempotent for a
130
- * given options set if you want caching to work across requests.
131
- */
132
- getKey(options: TOptions): string;
133
- }
134
-
135
- export type ResponseCache = $ReadOnly<Cache>;
62
+ [scope: string]: {
63
+ /**
64
+ * Each value in the cache is then identified within a given scope.
65
+ */
66
+ [id: string]: ValidCacheData,
67
+ ...
68
+ },
69
+ ...
70
+ };