@khanacademy/wonder-blocks-data 3.1.3 → 5.0.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 (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/es/index.js +408 -349
  3. package/dist/index.js +599 -494
  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 +60 -126
  8. package/src/components/__tests__/data.test.js +373 -313
  9. package/src/components/__tests__/intercept-requests.test.js +58 -0
  10. package/src/components/data.js +139 -21
  11. package/src/components/data.md +38 -69
  12. package/src/components/intercept-context.js +6 -3
  13. package/src/components/intercept-requests.js +69 -0
  14. package/src/components/intercept-requests.md +54 -0
  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-gql.test.js +1 -0
  18. package/src/hooks/__tests__/use-request-interception.test.js +255 -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 +36 -23
  22. package/src/hooks/use-request-interception.js +54 -0
  23. package/src/hooks/use-server-effect.js +45 -0
  24. package/src/hooks/use-shared-cache.js +106 -0
  25. package/src/index.js +18 -20
  26. package/src/util/__tests__/__snapshots__/scoped-in-memory-cache.test.js.snap +19 -0
  27. package/src/util/__tests__/request-fulfillment.test.js +42 -85
  28. package/src/util/__tests__/request-tracking.test.js +72 -191
  29. package/src/util/__tests__/{result-from-cache-entry.test.js → result-from-cache-response.test.js} +9 -10
  30. package/src/util/__tests__/scoped-in-memory-cache.test.js +396 -0
  31. package/src/util/__tests__/ssr-cache.test.js +639 -0
  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/components/__tests__/intercept-data.test.js +0 -87
  39. package/src/components/intercept-data.js +0 -77
  40. package/src/components/intercept-data.md +0 -65
  41. package/src/hooks/__tests__/use-data.test.js +0 -826
  42. package/src/hooks/use-data.js +0 -143
  43. package/src/util/__tests__/memory-cache.test.js +0 -446
  44. package/src/util/__tests__/request-handler.test.js +0 -121
  45. package/src/util/__tests__/response-cache.test.js +0 -879
  46. package/src/util/memory-cache.js +0 -187
  47. package/src/util/request-handler.js +0 -42
  48. package/src/util/request-handler.md +0 -51
  49. 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
+ };
@@ -1,87 +0,0 @@
1
- // @flow
2
- import * as React from "react";
3
- import {render} from "@testing-library/react";
4
-
5
- import InterceptContext from "../intercept-context.js";
6
- import InterceptData from "../intercept-data.js";
7
-
8
- import type {IRequestHandler} from "../../util/types.js";
9
-
10
- describe("InterceptData", () => {
11
- afterEach(() => {
12
- jest.resetAllMocks();
13
- });
14
-
15
- it("should update context with fulfillRequest method", () => {
16
- // Arrange
17
- const fakeHandler: IRequestHandler<string, string> = {
18
- fulfillRequest: () => Promise.resolve("data"),
19
- getKey: (o) => o,
20
- type: "MY_HANDLER",
21
- hydrate: true,
22
- };
23
- const props = {
24
- handler: fakeHandler,
25
- fulfillRequest: jest.fn(),
26
- };
27
- const captureContextFn = jest.fn();
28
-
29
- // Act
30
- render(
31
- <InterceptData {...props}>
32
- <InterceptContext.Consumer>
33
- {captureContextFn}
34
- </InterceptContext.Consumer>
35
- </InterceptData>,
36
- );
37
-
38
- // Assert
39
- expect(captureContextFn).toHaveBeenCalledWith(
40
- expect.objectContaining({
41
- MY_HANDLER: {
42
- fulfillRequest: props.fulfillRequest,
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
- type: "MY_HANDLER",
54
- cache: null,
55
- hydrate: true,
56
- };
57
- const fulfillRequest1Fn = jest.fn();
58
- const fulfillRequest2Fn = jest.fn();
59
- const captureContextFn = jest.fn();
60
-
61
- // Act
62
- render(
63
- <InterceptData
64
- handler={fakeHandler}
65
- fulfillRequest={fulfillRequest1Fn}
66
- >
67
- <InterceptData
68
- handler={fakeHandler}
69
- fulfillRequest={fulfillRequest2Fn}
70
- >
71
- <InterceptContext.Consumer>
72
- {captureContextFn}
73
- </InterceptContext.Consumer>
74
- </InterceptData>
75
- </InterceptData>,
76
- );
77
-
78
- // Assert
79
- expect(captureContextFn).toHaveBeenCalledWith(
80
- expect.objectContaining({
81
- MY_HANDLER: {
82
- fulfillRequest: fulfillRequest2Fn,
83
- },
84
- }),
85
- );
86
- });
87
- });
@@ -1,77 +0,0 @@
1
- // @flow
2
- import * as React from "react";
3
-
4
- import InterceptContext from "./intercept-context.js";
5
-
6
- import type {
7
- ValidData,
8
- IRequestHandler,
9
- InterceptFulfillRequestFn,
10
- } from "../util/types.js";
11
-
12
- type Props<TOptions, TData> = {|
13
- /**
14
- * A handler of the type to be intercepted.
15
- */
16
- handler: IRequestHandler<TOptions, TData>,
17
-
18
- /**
19
- * The children to render within this component. Any requests by `Data`
20
- * components that use a handler of the same type as the handler for this
21
- * component that are rendered within these children will be intercepted by
22
- * this component (unless another `InterceptData` component overrides this
23
- * one).
24
- */
25
- children: React.Node,
26
-
27
- /**
28
- * Called to fulfill a request.
29
- * If this returns null, the request will be fulfilled by the
30
- * handler of the original request being intercepted.
31
- */
32
- fulfillRequest: InterceptFulfillRequestFn<TOptions, TData>,
33
- |};
34
-
35
- /**
36
- * This component provides a mechanism to intercept the data requests for the
37
- * type of a given handler and provide alternative results. This is mostly
38
- * useful for testing.
39
- *
40
- * This component is not recommended for use in production code as it
41
- * can prevent predictable functioning of the Wonder Blocks Data framework.
42
- * One possible side-effect is that inflight requests from the interceptor could
43
- * be picked up by `Data` component requests of the same handler type from
44
- * outside the children of this component.
45
- *
46
- * These components do not chain. If a different `InterceptData` instance is
47
- * rendered within this one that intercepts the same handler type, then that
48
- * new instance will replace this interceptor for its children. All methods
49
- * will be replaced.
50
- */
51
- export default class InterceptData<
52
- TOptions,
53
- TData: ValidData,
54
- > extends React.Component<Props<TOptions, TData>> {
55
- render(): React.Node {
56
- return (
57
- <InterceptContext.Consumer>
58
- {(value) => {
59
- const handlerType = this.props.handler.type;
60
- const interceptor = {
61
- ...value[handlerType],
62
- fulfillRequest: this.props.fulfillRequest,
63
- };
64
- const newValue = {
65
- ...value,
66
- [handlerType]: interceptor,
67
- };
68
- return (
69
- <InterceptContext.Provider value={newValue}>
70
- {this.props.children}
71
- </InterceptContext.Provider>
72
- );
73
- }}
74
- </InterceptContext.Consumer>
75
- );
76
- }
77
- }
@@ -1,65 +0,0 @@
1
- When you want to generate tests that check the loading state and
2
- subsequent loaded state are working correctly for your uses of `Data` you can
3
- use the `InterceptData` component.
4
-
5
- This component takes four props; children to be rendered, the handler of the
6
- type of data requests that are to be intercepted, and a `fulfillRequest`.
7
-
8
- Note that this component is expected to be used only within test cases and
9
- usually only as a single instance. In flight requests for a given handler
10
- type can be shared and as such, using `InterceptData` alongside non-intercepted
11
- `Data` components with the same handler type can have indeterminate outcomes.
12
-
13
- The `fulfillRequest` intercept function has the form:
14
-
15
- ```js static
16
- (options: TOptions) => ?Promise<TData>;
17
- ```
18
-
19
- If this method returns `null`, the default behavior occurs. This
20
- means that a request will be made for data via the handler assigned to the
21
- `Data` component being intercepted.
22
-
23
- ```jsx
24
- import {Body, BodyMonospace} from "@khanacademy/wonder-blocks-typography";
25
- import {View} from "@khanacademy/wonder-blocks-core";
26
- import {InterceptData, Data, RequestHandler} from "@khanacademy/wonder-blocks-data";
27
- import {Strut} from "@khanacademy/wonder-blocks-layout";
28
- import Color from "@khanacademy/wonder-blocks-color";
29
- import Spacing from "@khanacademy/wonder-blocks-spacing";
30
-
31
- class MyHandler extends RequestHandler {
32
- constructor() {
33
- super("INTERCEPT_DATA_HANDLER1");
34
- }
35
-
36
- fulfillRequest(options) {
37
- return Promise.reject(new Error("You should not see this!"));
38
- }
39
- }
40
-
41
- const handler = new MyHandler();
42
- const fulfillRequestInterceptor = function(options) {
43
- if (options === "DATA") {
44
- return Promise.resolve("INTERCEPTED DATA!");
45
- }
46
- return null;
47
- };
48
-
49
- <InterceptData handler={handler} fulfillRequest={fulfillRequestInterceptor}>
50
- <View>
51
- <Body>This received intercepted data!</Body>
52
- <Data handler={handler} options={"DATA"}>
53
- {({loading, data}) => {
54
- if (loading) {
55
- return "If you see this, the example is broken!";
56
- }
57
-
58
- return (
59
- <BodyMonospace>{data}</BodyMonospace>
60
- );
61
- }}
62
- </Data>
63
- </View>
64
- </InterceptData>
65
- ```