@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.
- package/CHANGELOG.md +23 -0
- package/dist/es/index.js +356 -332
- package/dist/index.js +507 -456
- package/docs.md +17 -35
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +7 -46
- package/src/__tests__/generated-snapshot.test.js +56 -122
- package/src/components/__tests__/data.test.js +372 -297
- package/src/components/__tests__/intercept-data.test.js +6 -30
- package/src/components/data.js +153 -21
- package/src/components/data.md +38 -69
- package/src/components/intercept-context.js +6 -2
- package/src/components/intercept-data.js +40 -51
- package/src/components/intercept-data.md +13 -27
- package/src/components/track-data.md +9 -23
- package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +17 -0
- package/src/hooks/__tests__/use-server-effect.test.js +217 -0
- package/src/hooks/__tests__/use-shared-cache.test.js +307 -0
- package/src/hooks/use-server-effect.js +45 -0
- package/src/hooks/use-shared-cache.js +106 -0
- package/src/index.js +15 -19
- package/src/util/__tests__/__snapshots__/scoped-in-memory-cache.test.js.snap +19 -0
- package/src/util/__tests__/request-fulfillment.test.js +42 -85
- package/src/util/__tests__/request-tracking.test.js +72 -191
- package/src/util/__tests__/{result-from-cache-entry.test.js → result-from-cache-response.test.js} +9 -10
- package/src/util/__tests__/scoped-in-memory-cache.test.js +396 -0
- package/src/util/__tests__/ssr-cache.test.js +639 -0
- package/src/util/request-fulfillment.js +36 -44
- package/src/util/request-tracking.js +62 -75
- package/src/util/{result-from-cache-entry.js → result-from-cache-response.js} +10 -13
- package/src/util/scoped-in-memory-cache.js +149 -0
- package/src/util/ssr-cache.js +206 -0
- package/src/util/types.js +43 -108
- package/src/hooks/__tests__/use-data.test.js +0 -826
- package/src/hooks/use-data.js +0 -143
- package/src/util/__tests__/memory-cache.test.js +0 -446
- package/src/util/__tests__/request-handler.test.js +0 -121
- package/src/util/__tests__/response-cache.test.js +0 -879
- package/src/util/memory-cache.js +0 -187
- package/src/util/request-handler.js +0 -42
- package/src/util/request-handler.md +0 -51
- package/src/util/response-cache.js +0 -213
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
|
|
4
|
+
import {ScopedInMemoryCache} from "../util/scoped-in-memory-cache.js";
|
|
5
|
+
import type {ValidCacheData} from "../util/types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A function for inserting a value into the cache or clearing it.
|
|
9
|
+
*/
|
|
10
|
+
type CacheValueFn<TValue: ValidCacheData> = (value: ?TValue) => void;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* This is the cache.
|
|
14
|
+
* It's incredibly complex.
|
|
15
|
+
* Very in-memory. So cache. Such complex. Wow.
|
|
16
|
+
*/
|
|
17
|
+
const cache = new ScopedInMemoryCache();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clear the in-memory cache or a single scope within it.
|
|
21
|
+
*/
|
|
22
|
+
export const clearSharedCache = (scope: string = "") => {
|
|
23
|
+
// If we have a valid scope (empty string is falsy), then clear that scope.
|
|
24
|
+
if (scope && typeof scope === "string") {
|
|
25
|
+
cache.purgeScope(scope);
|
|
26
|
+
} else {
|
|
27
|
+
// Just reset the object. This should be sufficient.
|
|
28
|
+
cache.purgeAll();
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hook to retrieve data from and store data in an in-memory cache.
|
|
34
|
+
*
|
|
35
|
+
* @returns {[?ReadOnlyCacheValue, CacheValueFn]}
|
|
36
|
+
* Returns an array containing the current cache entry (or undefined), a
|
|
37
|
+
* function to set the cache entry (passing null or undefined to this function
|
|
38
|
+
* will delete the entry).
|
|
39
|
+
*
|
|
40
|
+
* To clear a single scope within the cache or the entire cache,
|
|
41
|
+
* the `clearScopedCache` export is available.
|
|
42
|
+
*
|
|
43
|
+
* NOTE: Unlike useState or useReducer, we don't automatically update folks
|
|
44
|
+
* if the value they reference changes. We might add it later (if we need to),
|
|
45
|
+
* but the likelihood here is that things won't be changing in this cache in a
|
|
46
|
+
* way where we would need that. If we do (and likely only in specific
|
|
47
|
+
* circumstances), we should consider adding a simple boolean useState that can
|
|
48
|
+
* be toggled to cause a rerender whenever the referenced cached data changes
|
|
49
|
+
* so that callers can re-render on cache changes. However, we should make
|
|
50
|
+
* sure this toggling is optional - or we could use a callback argument, to
|
|
51
|
+
* achieve this on an as-needed basis.
|
|
52
|
+
*/
|
|
53
|
+
export const useSharedCache = <TValue: ValidCacheData>(
|
|
54
|
+
id: string,
|
|
55
|
+
scope: string,
|
|
56
|
+
initialValue?: ?TValue | (() => ?TValue),
|
|
57
|
+
): [?TValue, CacheValueFn<TValue>] => {
|
|
58
|
+
// Verify arguments.
|
|
59
|
+
if (!id || typeof id !== "string") {
|
|
60
|
+
throw new KindError(
|
|
61
|
+
"id must be a non-empty string",
|
|
62
|
+
Errors.InvalidInput,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!scope || typeof scope !== "string") {
|
|
67
|
+
throw new KindError(
|
|
68
|
+
"scope must be a non-empty string",
|
|
69
|
+
Errors.InvalidInput,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Memoize our APIs.
|
|
74
|
+
// This one allows callers to set or replace the cached value.
|
|
75
|
+
const cacheValue = React.useMemo(
|
|
76
|
+
() => (value: ?TValue) =>
|
|
77
|
+
value == null
|
|
78
|
+
? cache.purge(scope, id)
|
|
79
|
+
: cache.set(scope, id, value),
|
|
80
|
+
[id, scope],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// We don't memo-ize the current value, just in case the cache was updated
|
|
84
|
+
// since our last run through. Also, our cache does not know what type it
|
|
85
|
+
// stores, so we have to cast it to the type we're exporting. This is a
|
|
86
|
+
// dev time courtesy, rather than a runtime thing.
|
|
87
|
+
// $FlowIgnore[incompatible-type]
|
|
88
|
+
let currentValue: ?TValue = cache.get(scope, id);
|
|
89
|
+
|
|
90
|
+
// If we have an initial value, we need to add it to the cache
|
|
91
|
+
// and use it as our current value.
|
|
92
|
+
if (currentValue == null && initialValue !== undefined) {
|
|
93
|
+
// Get the initial value.
|
|
94
|
+
const value =
|
|
95
|
+
typeof initialValue === "function" ? initialValue() : initialValue;
|
|
96
|
+
|
|
97
|
+
// Update the cache.
|
|
98
|
+
cacheValue(value);
|
|
99
|
+
|
|
100
|
+
// Make sure we return this value as our current value.
|
|
101
|
+
currentValue = value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Now we have everything, let's return it.
|
|
105
|
+
return [currentValue, cacheValue];
|
|
106
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import {Server} from "@khanacademy/wonder-blocks-core";
|
|
3
|
-
import {
|
|
3
|
+
import {SsrCache} from "./util/ssr-cache.js";
|
|
4
4
|
import {RequestTracker} from "./util/request-tracking.js";
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
IRequestHandler,
|
|
7
|
+
ValidCacheData,
|
|
8
|
+
CachedResponse,
|
|
10
9
|
ResponseCache,
|
|
11
10
|
} from "./util/types.js";
|
|
12
11
|
|
|
13
12
|
export type {
|
|
14
|
-
Cache,
|
|
15
|
-
CacheEntry,
|
|
16
|
-
Result,
|
|
17
|
-
IRequestHandler,
|
|
18
13
|
ResponseCache,
|
|
14
|
+
CachedResponse,
|
|
15
|
+
Result,
|
|
16
|
+
ScopedCache,
|
|
19
17
|
} from "./util/types.js";
|
|
20
18
|
|
|
21
19
|
export const initializeCache = (source: ResponseCache): void =>
|
|
22
|
-
|
|
20
|
+
SsrCache.Default.initialize(source);
|
|
23
21
|
|
|
24
22
|
export const fulfillAllDataRequests = (): Promise<ResponseCache> => {
|
|
25
23
|
if (!Server.isServerSide()) {
|
|
@@ -37,24 +35,22 @@ export const hasUnfulfilledRequests = (): boolean => {
|
|
|
37
35
|
return RequestTracker.Default.hasUnfulfilledRequests;
|
|
38
36
|
};
|
|
39
37
|
|
|
40
|
-
export const removeFromCache =
|
|
41
|
-
|
|
42
|
-
options: TOptions,
|
|
43
|
-
): boolean => ResCache.Default.remove<TOptions, TData>(handler, options);
|
|
38
|
+
export const removeFromCache = (id: string): boolean =>
|
|
39
|
+
SsrCache.Default.remove(id);
|
|
44
40
|
|
|
45
|
-
export const removeAllFromCache =
|
|
46
|
-
handler: IRequestHandler<TOptions, TData>,
|
|
41
|
+
export const removeAllFromCache = (
|
|
47
42
|
predicate?: (
|
|
48
43
|
key: string,
|
|
49
|
-
cacheEntry: ?$ReadOnly<
|
|
44
|
+
cacheEntry: ?$ReadOnly<CachedResponse<ValidCacheData>>,
|
|
50
45
|
) => boolean,
|
|
51
|
-
):
|
|
46
|
+
): void => SsrCache.Default.removeAll(predicate);
|
|
52
47
|
|
|
53
|
-
export {default as RequestHandler} from "./util/request-handler.js";
|
|
54
48
|
export {default as TrackData} from "./components/track-data.js";
|
|
55
49
|
export {default as Data} from "./components/data.js";
|
|
56
50
|
export {default as InterceptData} from "./components/intercept-data.js";
|
|
57
|
-
export {
|
|
51
|
+
export {useServerEffect} from "./hooks/use-server-effect.js";
|
|
52
|
+
export {useSharedCache, clearSharedCache} from "./hooks/use-shared-cache.js";
|
|
53
|
+
export {ScopedInMemoryCache} from "./util/scoped-in-memory-cache.js";
|
|
58
54
|
|
|
59
55
|
// GraphQL
|
|
60
56
|
export {GqlRouter} from "./components/gql-router.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
|
+
|
|
3
|
+
exports[`ScopedInMemoryCache #set should throw if the id is 1`] = `"id must be non-empty string"`;
|
|
4
|
+
|
|
5
|
+
exports[`ScopedInMemoryCache #set should throw if the id is [Function anonymous] 1`] = `"id must be non-empty string"`;
|
|
6
|
+
|
|
7
|
+
exports[`ScopedInMemoryCache #set should throw if the id is 5 1`] = `"id must be non-empty string"`;
|
|
8
|
+
|
|
9
|
+
exports[`ScopedInMemoryCache #set should throw if the id is null 1`] = `"id must be non-empty string"`;
|
|
10
|
+
|
|
11
|
+
exports[`ScopedInMemoryCache #set should throw if the scope is 1`] = `"scope must be non-empty string"`;
|
|
12
|
+
|
|
13
|
+
exports[`ScopedInMemoryCache #set should throw if the scope is [Function anonymous] 1`] = `"scope must be non-empty string"`;
|
|
14
|
+
|
|
15
|
+
exports[`ScopedInMemoryCache #set should throw if the scope is 5 1`] = `"scope must be non-empty string"`;
|
|
16
|
+
|
|
17
|
+
exports[`ScopedInMemoryCache #set should throw if the scope is null 1`] = `"scope must be non-empty string"`;
|
|
18
|
+
|
|
19
|
+
exports[`ScopedInMemoryCache #set should throw if the value is a function 1`] = `"value must be a non-function value"`;
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// @flow
|
|
2
|
-
import {
|
|
2
|
+
import {SsrCache} from "../ssr-cache.js";
|
|
3
3
|
import {RequestFulfillment} from "../request-fulfillment.js";
|
|
4
4
|
|
|
5
|
-
import type {IRequestHandler} from "../types.js";
|
|
6
|
-
|
|
7
5
|
describe("RequestFulfillment", () => {
|
|
8
6
|
it("should provide static default instance", () => {
|
|
9
7
|
// Arrange
|
|
@@ -19,93 +17,66 @@ describe("RequestFulfillment", () => {
|
|
|
19
17
|
describe("#fulfill", () => {
|
|
20
18
|
it("should attempt to cache errors caused directly by handlers", async () => {
|
|
21
19
|
// Arrange
|
|
22
|
-
const responseCache = new
|
|
20
|
+
const responseCache = new SsrCache();
|
|
23
21
|
const requestFulfillment = new RequestFulfillment(responseCache);
|
|
24
22
|
const error = new Error("OH NO!");
|
|
25
|
-
const fakeBadHandler
|
|
26
|
-
|
|
27
|
-
throw error;
|
|
28
|
-
},
|
|
29
|
-
getKey: jest.fn().mockReturnValue("MY_KEY"),
|
|
30
|
-
type: "MY_TYPE",
|
|
31
|
-
hydrate: true,
|
|
23
|
+
const fakeBadHandler = () => {
|
|
24
|
+
throw error;
|
|
32
25
|
};
|
|
33
26
|
const cacheErrorSpy = jest.spyOn(responseCache, "cacheError");
|
|
34
27
|
|
|
35
28
|
// Act
|
|
36
|
-
await requestFulfillment.fulfill(
|
|
29
|
+
await requestFulfillment.fulfill("ID", {
|
|
30
|
+
handler: fakeBadHandler,
|
|
31
|
+
});
|
|
37
32
|
|
|
38
33
|
// Assert
|
|
39
|
-
expect(cacheErrorSpy).toHaveBeenCalledWith(
|
|
40
|
-
fakeBadHandler,
|
|
41
|
-
"OPTIONS",
|
|
42
|
-
error,
|
|
43
|
-
);
|
|
34
|
+
expect(cacheErrorSpy).toHaveBeenCalledWith("ID", error, true);
|
|
44
35
|
});
|
|
45
36
|
|
|
46
37
|
it("should cache errors occurring in promises", async () => {
|
|
47
38
|
// Arrange
|
|
48
|
-
const responseCache = new
|
|
39
|
+
const responseCache = new SsrCache();
|
|
49
40
|
const requestFulfillment = new RequestFulfillment(responseCache);
|
|
50
|
-
const fakeBadRequestHandler
|
|
51
|
-
|
|
52
|
-
new Promise((resolve, reject) => reject("OH NO!")),
|
|
53
|
-
getKey: (o) => o,
|
|
54
|
-
type: "BAD_REQUEST",
|
|
55
|
-
hydrate: true,
|
|
56
|
-
};
|
|
41
|
+
const fakeBadRequestHandler = () =>
|
|
42
|
+
new Promise((resolve, reject) => reject("OH NO!"));
|
|
57
43
|
const cacheErrorSpy = jest.spyOn(responseCache, "cacheError");
|
|
58
44
|
|
|
59
45
|
// Act
|
|
60
|
-
await requestFulfillment.fulfill(
|
|
46
|
+
await requestFulfillment.fulfill("ID", {
|
|
47
|
+
handler: fakeBadRequestHandler,
|
|
48
|
+
});
|
|
61
49
|
|
|
62
50
|
// Assert
|
|
63
|
-
expect(cacheErrorSpy).toHaveBeenCalledWith(
|
|
64
|
-
fakeBadRequestHandler,
|
|
65
|
-
"OPTIONS",
|
|
66
|
-
"OH NO!",
|
|
67
|
-
);
|
|
51
|
+
expect(cacheErrorSpy).toHaveBeenCalledWith("ID", "OH NO!", true);
|
|
68
52
|
});
|
|
69
53
|
|
|
70
54
|
it("should cache data from requests", async () => {
|
|
71
55
|
// Arrange
|
|
72
|
-
const responseCache = new
|
|
56
|
+
const responseCache = new SsrCache();
|
|
73
57
|
const requestFulfillment = new RequestFulfillment(responseCache);
|
|
74
|
-
const fakeRequestHandler
|
|
75
|
-
fulfillRequest: () => Promise.resolve("DATA!"),
|
|
76
|
-
getKey: (o) => o,
|
|
77
|
-
type: "VALID_REQUEST",
|
|
78
|
-
hydrate: true,
|
|
79
|
-
};
|
|
58
|
+
const fakeRequestHandler = () => Promise.resolve("DATA!");
|
|
80
59
|
const cacheDataSpy = jest.spyOn(responseCache, "cacheData");
|
|
81
60
|
|
|
82
61
|
// Act
|
|
83
|
-
await requestFulfillment.fulfill(
|
|
62
|
+
await requestFulfillment.fulfill("ID", {
|
|
63
|
+
handler: fakeRequestHandler,
|
|
64
|
+
});
|
|
84
65
|
|
|
85
66
|
// Assert
|
|
86
|
-
expect(cacheDataSpy).toHaveBeenCalledWith(
|
|
87
|
-
fakeRequestHandler,
|
|
88
|
-
"OPTIONS",
|
|
89
|
-
"DATA!",
|
|
90
|
-
);
|
|
67
|
+
expect(cacheDataSpy).toHaveBeenCalledWith("ID", "DATA!", true);
|
|
91
68
|
});
|
|
92
69
|
|
|
93
70
|
it("should return a promise of the result", async () => {
|
|
94
71
|
// Arrange
|
|
95
|
-
const responseCache = new
|
|
72
|
+
const responseCache = new SsrCache();
|
|
96
73
|
const requestFulfillment = new RequestFulfillment(responseCache);
|
|
97
|
-
const fakeRequestHandler
|
|
98
|
-
fulfillRequest: () => Promise.resolve("DATA!"),
|
|
99
|
-
getKey: (o) => o,
|
|
100
|
-
type: "VALID_REQUEST",
|
|
101
|
-
hydrate: true,
|
|
102
|
-
};
|
|
74
|
+
const fakeRequestHandler = () => Promise.resolve("DATA!");
|
|
103
75
|
|
|
104
76
|
// Act
|
|
105
|
-
const result = await requestFulfillment.fulfill(
|
|
106
|
-
fakeRequestHandler,
|
|
107
|
-
|
|
108
|
-
);
|
|
77
|
+
const result = await requestFulfillment.fulfill("ID", {
|
|
78
|
+
handler: fakeRequestHandler,
|
|
79
|
+
});
|
|
109
80
|
|
|
110
81
|
// Assert
|
|
111
82
|
expect(result).toStrictEqual({
|
|
@@ -115,24 +86,17 @@ describe("RequestFulfillment", () => {
|
|
|
115
86
|
|
|
116
87
|
it("should reuse inflight requests", () => {
|
|
117
88
|
// Arrange
|
|
118
|
-
const responseCache = new
|
|
89
|
+
const responseCache = new SsrCache();
|
|
119
90
|
const requestFulfillment = new RequestFulfillment(responseCache);
|
|
120
|
-
const fakeRequestHandler
|
|
121
|
-
fulfillRequest: () => Promise.resolve("DATA!"),
|
|
122
|
-
getKey: (o) => o,
|
|
123
|
-
type: "VALID_REQUEST",
|
|
124
|
-
hydrate: true,
|
|
125
|
-
};
|
|
91
|
+
const fakeRequestHandler = () => Promise.resolve("DATA!");
|
|
126
92
|
|
|
127
93
|
// Act
|
|
128
|
-
const promise = requestFulfillment.fulfill(
|
|
129
|
-
fakeRequestHandler,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
"OPTIONS",
|
|
135
|
-
);
|
|
94
|
+
const promise = requestFulfillment.fulfill("ID", {
|
|
95
|
+
handler: fakeRequestHandler,
|
|
96
|
+
});
|
|
97
|
+
const result = requestFulfillment.fulfill("ID", {
|
|
98
|
+
handler: fakeRequestHandler,
|
|
99
|
+
});
|
|
136
100
|
|
|
137
101
|
// Assert
|
|
138
102
|
expect(result).toBe(promise);
|
|
@@ -140,25 +104,18 @@ describe("RequestFulfillment", () => {
|
|
|
140
104
|
|
|
141
105
|
it("should remove inflight requests upon completion", async () => {
|
|
142
106
|
// Arrange
|
|
143
|
-
const responseCache = new
|
|
107
|
+
const responseCache = new SsrCache();
|
|
144
108
|
const requestFulfillment = new RequestFulfillment(responseCache);
|
|
145
|
-
const fakeRequestHandler
|
|
146
|
-
fulfillRequest: () => Promise.resolve("DATA!"),
|
|
147
|
-
getKey: (o) => o,
|
|
148
|
-
type: "VALID_REQUEST",
|
|
149
|
-
hydrate: false,
|
|
150
|
-
};
|
|
109
|
+
const fakeRequestHandler = () => Promise.resolve("DATA!");
|
|
151
110
|
|
|
152
111
|
// Act
|
|
153
|
-
const promise = requestFulfillment.fulfill(
|
|
154
|
-
fakeRequestHandler,
|
|
155
|
-
|
|
156
|
-
);
|
|
112
|
+
const promise = requestFulfillment.fulfill("ID", {
|
|
113
|
+
handler: fakeRequestHandler,
|
|
114
|
+
});
|
|
157
115
|
await promise;
|
|
158
|
-
const result = requestFulfillment.fulfill(
|
|
159
|
-
fakeRequestHandler,
|
|
160
|
-
|
|
161
|
-
);
|
|
116
|
+
const result = requestFulfillment.fulfill("ID", {
|
|
117
|
+
handler: fakeRequestHandler,
|
|
118
|
+
});
|
|
162
119
|
|
|
163
120
|
// Assert
|
|
164
121
|
expect(result).not.toBe(promise);
|