@khanacademy/wonder-blocks-data 3.1.2 → 5.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 +41 -0
- package/dist/es/index.js +408 -349
- package/dist/index.js +568 -467
- 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 +60 -126
- package/src/components/__tests__/data.test.js +373 -313
- package/src/components/__tests__/intercept-requests.test.js +58 -0
- package/src/components/data.js +139 -21
- package/src/components/data.md +38 -69
- package/src/components/gql-router.js +1 -1
- package/src/components/intercept-context.js +6 -3
- package/src/components/intercept-requests.js +69 -0
- package/src/components/intercept-requests.md +54 -0
- 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-gql.test.js +1 -0
- package/src/hooks/__tests__/use-request-interception.test.js +255 -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-gql.js +39 -31
- package/src/hooks/use-request-interception.js +54 -0
- package/src/hooks/use-server-effect.js +45 -0
- package/src/hooks/use-shared-cache.js +106 -0
- package/src/index.js +17 -20
- 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/gql-types.js +5 -10
- 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/components/__tests__/intercept-data.test.js +0 -87
- package/src/components/intercept-data.js +0 -77
- package/src/components/intercept-data.md +0 -65
- 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,58 @@
|
|
|
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 InterceptRequests from "../intercept-requests.js";
|
|
7
|
+
|
|
8
|
+
describe("InterceptRequests", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
jest.resetAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should update context with fulfillRequest method", () => {
|
|
14
|
+
// Arrange
|
|
15
|
+
const fakeHandler = (requestId): Promise<string> =>
|
|
16
|
+
Promise.resolve("data");
|
|
17
|
+
const props = {
|
|
18
|
+
interceptor: fakeHandler,
|
|
19
|
+
};
|
|
20
|
+
const captureContextFn = jest.fn();
|
|
21
|
+
|
|
22
|
+
// Act
|
|
23
|
+
render(
|
|
24
|
+
<InterceptRequests {...props}>
|
|
25
|
+
<InterceptContext.Consumer>
|
|
26
|
+
{captureContextFn}
|
|
27
|
+
</InterceptContext.Consumer>
|
|
28
|
+
</InterceptRequests>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Assert
|
|
32
|
+
expect(captureContextFn).toHaveBeenCalledWith([fakeHandler]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should override parent InterceptRequests", () => {
|
|
36
|
+
// Arrange
|
|
37
|
+
const fakeHandler1 = jest.fn();
|
|
38
|
+
const fakeHandler2 = jest.fn();
|
|
39
|
+
const captureContextFn = jest.fn();
|
|
40
|
+
|
|
41
|
+
// Act
|
|
42
|
+
render(
|
|
43
|
+
<InterceptRequests interceptor={fakeHandler1}>
|
|
44
|
+
<InterceptRequests interceptor={fakeHandler2}>
|
|
45
|
+
<InterceptContext.Consumer>
|
|
46
|
+
{captureContextFn}
|
|
47
|
+
</InterceptContext.Consumer>
|
|
48
|
+
</InterceptRequests>
|
|
49
|
+
</InterceptRequests>,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Assert
|
|
53
|
+
expect(captureContextFn).toHaveBeenCalledWith([
|
|
54
|
+
fakeHandler1,
|
|
55
|
+
fakeHandler2,
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/components/data.js
CHANGED
|
@@ -1,36 +1,63 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {Server} from "@khanacademy/wonder-blocks-core";
|
|
5
|
+
import {RequestFulfillment} from "../util/request-fulfillment.js";
|
|
6
|
+
import {useServerEffect} from "../hooks/use-server-effect.js";
|
|
7
|
+
import {useRequestInterception} from "../hooks/use-request-interception.js";
|
|
8
|
+
import {resultFromCachedResponse} from "../util/result-from-cache-response.js";
|
|
5
9
|
|
|
6
|
-
import type {Result,
|
|
10
|
+
import type {Result, ValidCacheData} from "../util/types.js";
|
|
7
11
|
|
|
8
12
|
type Props<
|
|
9
|
-
/**
|
|
10
|
-
* The type of options that the handler requires to define a request.
|
|
11
|
-
*/
|
|
12
|
-
TOptions,
|
|
13
13
|
/**
|
|
14
14
|
* The type of data resolved by the handler's fulfillRequest method.
|
|
15
15
|
*/
|
|
16
|
-
TData,
|
|
16
|
+
TData: ValidCacheData,
|
|
17
17
|
> = {|
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* A unique identifier for the request.
|
|
20
|
+
*
|
|
21
|
+
* This should not be shared by other uses of this component.
|
|
22
|
+
*/
|
|
23
|
+
requestId: string,
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* This defines how the request is fulfilled.
|
|
21
27
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
28
|
+
* If this is changed without changing the ID, there are cases where the
|
|
29
|
+
* old handler result may be given. This is not a supported mode of
|
|
30
|
+
* operation.
|
|
24
31
|
*/
|
|
25
|
-
handler:
|
|
32
|
+
handler: () => Promise<?TData>,
|
|
26
33
|
|
|
27
34
|
/**
|
|
28
|
-
*
|
|
35
|
+
* When true, the result will be hydrated when client-side. Otherwise,
|
|
36
|
+
* the request will be fulfilled for us in SSR but will be ignored during
|
|
37
|
+
* hydration. Only set this to false if you know some other mechanism
|
|
38
|
+
* will be performing hydration (such as if requests are fulfilled by
|
|
39
|
+
* Apollo Client but you consolidated all SSR requests using WB Data).
|
|
29
40
|
*
|
|
30
|
-
*
|
|
31
|
-
* from `handler.getKey(options)` changes.
|
|
41
|
+
* Defaults to true.
|
|
32
42
|
*/
|
|
33
|
-
|
|
43
|
+
hydrate?: boolean,
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* When true, the children will be rendered with the existing result
|
|
47
|
+
* until the pending load is completed. Otherwise, the children will be
|
|
48
|
+
* given a loading state until the request is fulfilled.
|
|
49
|
+
*
|
|
50
|
+
* Defaults to false.
|
|
51
|
+
*/
|
|
52
|
+
showOldDataWhileLoading?: boolean,
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* When true, the handler will always be invoked after hydration.
|
|
56
|
+
* This defaults to false.
|
|
57
|
+
* NOTE: The request is invoked after hydration if the hydrated result
|
|
58
|
+
* is an error.
|
|
59
|
+
*/
|
|
60
|
+
alwaysRequestOnHydration?: boolean,
|
|
34
61
|
|
|
35
62
|
/**
|
|
36
63
|
* A function that will render the content of this component using the
|
|
@@ -45,10 +72,101 @@ type Props<
|
|
|
45
72
|
* requirements can be placed in a React application in a manner that will
|
|
46
73
|
* support server-side rendering and efficient caching.
|
|
47
74
|
*/
|
|
48
|
-
const Data = <
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
const Data = <TData: ValidCacheData>({
|
|
76
|
+
requestId,
|
|
77
|
+
handler,
|
|
78
|
+
children,
|
|
79
|
+
hydrate,
|
|
80
|
+
showOldDataWhileLoading,
|
|
81
|
+
alwaysRequestOnHydration,
|
|
82
|
+
}: Props<TData>): React.Node => {
|
|
83
|
+
const interceptedHandler = useRequestInterception(requestId, handler);
|
|
84
|
+
|
|
85
|
+
const hydrateResult = useServerEffect(
|
|
86
|
+
requestId,
|
|
87
|
+
interceptedHandler,
|
|
88
|
+
hydrate,
|
|
89
|
+
);
|
|
90
|
+
const [currentResult, setResult] = React.useState(hydrateResult);
|
|
91
|
+
|
|
92
|
+
// Here we make sure the request still occurs client-side as needed.
|
|
93
|
+
// This is for legacy usage that expects this. Eventually we will want
|
|
94
|
+
// to deprecate.
|
|
95
|
+
React.useEffect(() => {
|
|
96
|
+
// This is here until I can do a better documentation example for
|
|
97
|
+
// the TrackData docs.
|
|
98
|
+
// istanbul ignore next
|
|
99
|
+
if (Server.isServerSide()) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// We don't bother with this if we have hydration data and we're not
|
|
104
|
+
// forcing a request on hydration.
|
|
105
|
+
// We don't care if these things change after the first render,
|
|
106
|
+
// so we don't want them in the inputs array.
|
|
107
|
+
if (!alwaysRequestOnHydration && hydrateResult?.data != null) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// If we're not hydrating a result and we're not going to render
|
|
112
|
+
// with old data until we're loaded, we want to make sure we set our
|
|
113
|
+
// result to null so that we're in the loading state.
|
|
114
|
+
if (!showOldDataWhileLoading) {
|
|
115
|
+
// Mark ourselves as loading.
|
|
116
|
+
setResult(null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// We aren't server-side, so let's make the request.
|
|
120
|
+
// We don't need to use our built-in request fulfillment here if we
|
|
121
|
+
// don't want, but it does mean we'll share inflight requests for the
|
|
122
|
+
// same ID and the result will be in the same format as the
|
|
123
|
+
// hydrated value.
|
|
124
|
+
let cancel = false;
|
|
125
|
+
RequestFulfillment.Default.fulfill(requestId, {
|
|
126
|
+
handler: interceptedHandler,
|
|
127
|
+
})
|
|
128
|
+
.then((result) => {
|
|
129
|
+
if (cancel) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setResult(result);
|
|
133
|
+
return;
|
|
134
|
+
})
|
|
135
|
+
.catch((e) => {
|
|
136
|
+
if (cancel) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* We should never get here as errors in fulfillment are part
|
|
141
|
+
* of the `then`, but if we do.
|
|
142
|
+
*/
|
|
143
|
+
// eslint-disable-next-line no-console
|
|
144
|
+
console.error(
|
|
145
|
+
`Unexpected error occurred during data fulfillment: ${e}`,
|
|
146
|
+
);
|
|
147
|
+
setResult({
|
|
148
|
+
error: typeof e === "string" ? e : e.message,
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
cancel = true;
|
|
155
|
+
};
|
|
156
|
+
// If the handler changes, we don't care. The ID is what indicates
|
|
157
|
+
// the request that should be made and folks shouldn't be changing the
|
|
158
|
+
// handler without changing the ID as well.
|
|
159
|
+
// In addition, we don't want to include hydrateResult nor
|
|
160
|
+
// alwaysRequestOnHydration as them changinng after the first pass
|
|
161
|
+
// is irrelevant.
|
|
162
|
+
// Finally, we don't want to include showOldDataWhileLoading as that
|
|
163
|
+
// changing on its own is also not relevant. It only matters if the
|
|
164
|
+
// request itself changes. All of which is to say that we only
|
|
165
|
+
// run this effect for the ID changing.
|
|
166
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
167
|
+
}, [requestId]);
|
|
168
|
+
|
|
169
|
+
return children(resultFromCachedResponse(currentResult));
|
|
53
170
|
};
|
|
171
|
+
|
|
54
172
|
export default Data;
|
package/src/components/data.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
The `Data` component is the frontend piece of our data architecture
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
The `Data` component is the frontend piece of our data architecture.
|
|
2
|
+
It describes a data requirement in terms of a handler and an identifier.
|
|
3
|
+
It also has props to govern hydrate behavior as well as loading and client-side
|
|
4
|
+
request behavior.
|
|
5
5
|
|
|
6
6
|
The handler is responsible for fulfilling the request when asked to do so.
|
|
7
7
|
|
|
@@ -40,49 +40,30 @@ data or an error, we re-render.
|
|
|
40
40
|
```jsx
|
|
41
41
|
import {Body, BodyMonospace} from "@khanacademy/wonder-blocks-typography";
|
|
42
42
|
import {View} from "@khanacademy/wonder-blocks-core";
|
|
43
|
-
import {Data
|
|
43
|
+
import {Data} from "@khanacademy/wonder-blocks-data";
|
|
44
44
|
import {Strut} from "@khanacademy/wonder-blocks-layout";
|
|
45
45
|
import Color from "@khanacademy/wonder-blocks-color";
|
|
46
46
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
fulfillRequest(options) {
|
|
54
|
-
return new Promise((resolve, reject) =>
|
|
55
|
-
setTimeout(() => resolve("I'm DATA from a request"), 3000),
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
class MyInvalidHandler extends RequestHandler {
|
|
61
|
-
constructor() {
|
|
62
|
-
super("CACHE_MISS_HANDLER_ERROR");
|
|
63
|
-
}
|
|
48
|
+
const myValidHandler = () => new Promise((resolve, reject) =>
|
|
49
|
+
setTimeout(() => resolve("I'm DATA from a request"), 3000),
|
|
50
|
+
);
|
|
64
51
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const valid = new MyValidHandler();
|
|
73
|
-
const invalid = new MyInvalidHandler();
|
|
52
|
+
const myInvalidHandler = () => new Promise((resolve, reject) =>
|
|
53
|
+
setTimeout(() => reject("I'm an ERROR from a request"), 3000),
|
|
54
|
+
);
|
|
74
55
|
|
|
75
56
|
<View>
|
|
76
57
|
<View>
|
|
77
58
|
<Body>This request will succeed and give us data!</Body>
|
|
78
|
-
<Data handler={
|
|
79
|
-
{(
|
|
80
|
-
if (loading) {
|
|
59
|
+
<Data handler={myValidHandler} requestId="VALID">
|
|
60
|
+
{(result) => {
|
|
61
|
+
if (result.status === "loading") {
|
|
81
62
|
return "Loading...";
|
|
82
63
|
}
|
|
83
64
|
|
|
84
65
|
return (
|
|
85
|
-
<BodyMonospace>{data}</BodyMonospace>
|
|
66
|
+
<BodyMonospace>{result.data}</BodyMonospace>
|
|
86
67
|
);
|
|
87
68
|
}}
|
|
88
69
|
</Data>
|
|
@@ -90,14 +71,14 @@ const invalid = new MyInvalidHandler();
|
|
|
90
71
|
<Strut size={Spacing.small_12} />
|
|
91
72
|
<View>
|
|
92
73
|
<Body>This request will go boom and give us an error!</Body>
|
|
93
|
-
<Data handler={
|
|
94
|
-
{(
|
|
95
|
-
if (loading) {
|
|
74
|
+
<Data handler={myInvalidHandler} requestId="INVALID">
|
|
75
|
+
{(result) => {
|
|
76
|
+
if (result.status === "loading") {
|
|
96
77
|
return "Loading...";
|
|
97
78
|
}
|
|
98
79
|
|
|
99
80
|
return (
|
|
100
|
-
<BodyMonospace style={{color: Color.red}}>ERROR: {error}</BodyMonospace>
|
|
81
|
+
<BodyMonospace style={{color: Color.red}}>ERROR: {result.error}</BodyMonospace>
|
|
101
82
|
);
|
|
102
83
|
}}
|
|
103
84
|
</Data>
|
|
@@ -114,49 +95,37 @@ populated using the `initializeCache` method before rendering.
|
|
|
114
95
|
```jsx
|
|
115
96
|
import {Body, BodyMonospace} from "@khanacademy/wonder-blocks-typography";
|
|
116
97
|
import {View} from "@khanacademy/wonder-blocks-core";
|
|
117
|
-
import {Data,
|
|
98
|
+
import {Data, initializeCache} from "@khanacademy/wonder-blocks-data";
|
|
118
99
|
import {Strut} from "@khanacademy/wonder-blocks-layout";
|
|
119
100
|
import Color from "@khanacademy/wonder-blocks-color";
|
|
120
101
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
121
102
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* fulfillRequest should not get called as we already have data cached.
|
|
129
|
-
*/
|
|
130
|
-
fulfillRequest(options) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
"If you're seeing this error, the examples are broken and data isn't in the cache that should be.",
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
103
|
+
const myHandler = () => {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"If you're seeing this error, the examples are broken and data isn't in the cache that should be.",
|
|
106
|
+
);
|
|
107
|
+
};
|
|
136
108
|
|
|
137
|
-
const handler = new MyHandler();
|
|
138
109
|
initializeCache({
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
error: "I'm an ERROR from hydration cache"
|
|
145
|
-
}
|
|
110
|
+
DATA: {
|
|
111
|
+
data: "I'm DATA from the hydration cache"
|
|
112
|
+
},
|
|
113
|
+
ERROR: {
|
|
114
|
+
error: "I'm an ERROR from hydration cache"
|
|
146
115
|
}
|
|
147
116
|
});
|
|
148
117
|
|
|
149
118
|
<View>
|
|
150
119
|
<View>
|
|
151
120
|
<Body>This cache has data!</Body>
|
|
152
|
-
<Data handler={
|
|
153
|
-
{(
|
|
154
|
-
if (
|
|
121
|
+
<Data handler={myHandler} requestId="DATA">
|
|
122
|
+
{(result) => {
|
|
123
|
+
if (result.status !== "success") {
|
|
155
124
|
return "If you see this, the example is broken!";
|
|
156
125
|
}
|
|
157
126
|
|
|
158
127
|
return (
|
|
159
|
-
<BodyMonospace>{data}</BodyMonospace>
|
|
128
|
+
<BodyMonospace>{result.data}</BodyMonospace>
|
|
160
129
|
);
|
|
161
130
|
}}
|
|
162
131
|
</Data>
|
|
@@ -164,14 +133,14 @@ initializeCache({
|
|
|
164
133
|
<Strut size={Spacing.small_12} />
|
|
165
134
|
<View>
|
|
166
135
|
<Body>This cache has error!</Body>
|
|
167
|
-
<Data handler={
|
|
168
|
-
{(
|
|
169
|
-
if (
|
|
136
|
+
<Data handler={myHandler} requestId="ERROR">
|
|
137
|
+
{(result) => {
|
|
138
|
+
if (result.status !== "error") {
|
|
170
139
|
return "If you see this, the example is broken!";
|
|
171
140
|
}
|
|
172
141
|
|
|
173
142
|
return (
|
|
174
|
-
<BodyMonospace style={{color: Color.red}}>ERROR: {error}</BodyMonospace>
|
|
143
|
+
<BodyMonospace style={{color: Color.red}}>ERROR: {result.error}</BodyMonospace>
|
|
175
144
|
);
|
|
176
145
|
}}
|
|
177
146
|
</Data>
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import * as React from "react";
|
|
3
|
+
import type {ValidCacheData} from "../util/types.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
type InterceptContextData = $ReadOnlyArray<
|
|
6
|
+
(requestId: string) => ?Promise<?ValidCacheData>,
|
|
7
|
+
>;
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
|
-
* InterceptContext defines a map from
|
|
10
|
+
* InterceptContext defines a map from request ID to interception methods.
|
|
8
11
|
*
|
|
9
12
|
* INTERNAL USE ONLY
|
|
10
13
|
*/
|
|
11
14
|
const InterceptContext: React.Context<InterceptContextData> =
|
|
12
|
-
React.createContext<InterceptContextData>(
|
|
15
|
+
React.createContext<InterceptContextData>([]);
|
|
13
16
|
|
|
14
17
|
export default InterceptContext;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import InterceptContext from "./intercept-context.js";
|
|
5
|
+
|
|
6
|
+
import type {ValidCacheData} from "../util/types.js";
|
|
7
|
+
|
|
8
|
+
type Props<TData: ValidCacheData> = {|
|
|
9
|
+
/**
|
|
10
|
+
* Called to intercept and possibly handle the request.
|
|
11
|
+
* If this returns null, the request will be handled by ancestor
|
|
12
|
+
* any ancestor interceptors, and ultimately, the original request
|
|
13
|
+
* handler, otherwise, this interceptor is handling the request.
|
|
14
|
+
*
|
|
15
|
+
* Interceptors are called in ancestor precedence, with the closest
|
|
16
|
+
* interceptor ancestor being called first, and the furthest ancestor
|
|
17
|
+
* being called last.
|
|
18
|
+
*
|
|
19
|
+
* Beware: Interceptors do not care about what data they are intercepting,
|
|
20
|
+
* so make sure to only intercept requests that you recognize from the
|
|
21
|
+
* identifier.
|
|
22
|
+
*/
|
|
23
|
+
interceptor: (requestId: string) => ?Promise<?TData>,
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The children to render within this component. Any requests by `Data`
|
|
27
|
+
* components that use same ID as this component will be intercepted.
|
|
28
|
+
* If `InterceptRequests` is used within `children`, that interception will
|
|
29
|
+
* be given a chance to intercept first.
|
|
30
|
+
*/
|
|
31
|
+
children: React.Node,
|
|
32
|
+
|};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* This component provides a mechanism to intercept data requests.
|
|
36
|
+
* This is for use in testing.
|
|
37
|
+
*
|
|
38
|
+
* This component is not recommended for use in production code as it
|
|
39
|
+
* can prevent predictable functioning of the Wonder Blocks Data framework.
|
|
40
|
+
* One possible side-effect is that inflight requests from the interceptor could
|
|
41
|
+
* be picked up by `Data` component requests from outside the children of this
|
|
42
|
+
* component.
|
|
43
|
+
*
|
|
44
|
+
* Interceptions within the same component tree are chained such that the
|
|
45
|
+
* interceptor closest to the intercepted request is called first, and the
|
|
46
|
+
* furthest interceptor is called last.
|
|
47
|
+
*/
|
|
48
|
+
const InterceptRequests = <TData: ValidCacheData>({
|
|
49
|
+
interceptor,
|
|
50
|
+
children,
|
|
51
|
+
}: Props<TData>): React.Node => {
|
|
52
|
+
const interceptors = React.useContext(InterceptContext);
|
|
53
|
+
|
|
54
|
+
const updatedInterceptors = React.useMemo(
|
|
55
|
+
// We could build this in reverse order so that our hook that does
|
|
56
|
+
// the interception didn't have to use reduceRight, but I think it
|
|
57
|
+
// is easier to think about if we do this in component tree order.
|
|
58
|
+
() => [...interceptors, interceptor],
|
|
59
|
+
[interceptors, interceptor],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<InterceptContext.Provider value={updatedInterceptors}>
|
|
64
|
+
{children}
|
|
65
|
+
</InterceptContext.Provider>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default InterceptRequests;
|
|
@@ -0,0 +1,54 @@
|
|
|
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 `InterceptRequests` component. You can also use this component to
|
|
4
|
+
register request interceptors for any code that uses the `useRequestInterception`
|
|
5
|
+
hook.
|
|
6
|
+
|
|
7
|
+
This component takes the children to be rendered, and an interceptor function.
|
|
8
|
+
|
|
9
|
+
Note that this component is expected to be used only within test cases or
|
|
10
|
+
stories. Be careful want request IDs are matched to avoid intercepting the
|
|
11
|
+
wrong requests and remember that in-flight requests for a given request ID
|
|
12
|
+
can be shared - which means a bad request ID match could share requests across
|
|
13
|
+
different request IDs..
|
|
14
|
+
|
|
15
|
+
The `interceptor` intercept function has the form:
|
|
16
|
+
|
|
17
|
+
```js static
|
|
18
|
+
(requestId: string) => ?Promise<?TData>;
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
If this method returns `null`, then the next interceptor in the chain is
|
|
22
|
+
invoked, ultimately ending with the original handler. This
|
|
23
|
+
means that a request will be made for data via the handler assigned to the
|
|
24
|
+
`Data` component being intercepted if no interceptor handles the request first.
|
|
25
|
+
|
|
26
|
+
```jsx
|
|
27
|
+
import {Body, BodyMonospace} from "@khanacademy/wonder-blocks-typography";
|
|
28
|
+
import {View} from "@khanacademy/wonder-blocks-core";
|
|
29
|
+
import {InterceptRequests, Data} from "@khanacademy/wonder-blocks-data";
|
|
30
|
+
import {Strut} from "@khanacademy/wonder-blocks-layout";
|
|
31
|
+
import Color from "@khanacademy/wonder-blocks-color";
|
|
32
|
+
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
33
|
+
|
|
34
|
+
const myHandler = () => Promise.reject(new Error("You should not see this!"));
|
|
35
|
+
|
|
36
|
+
const interceptor = (requestId) => requestId === "INTERCEPT_EXAMPLE" ? Promise.resolve("INTERCEPTED DATA!") : null;
|
|
37
|
+
|
|
38
|
+
<InterceptRequests interceptor={interceptor}>
|
|
39
|
+
<View>
|
|
40
|
+
<Body>This received intercepted data!</Body>
|
|
41
|
+
<Data handler={myHandler} requestId="INTERCEPT_EXAMPLE">
|
|
42
|
+
{(result) => {
|
|
43
|
+
if (result.status !== "success") {
|
|
44
|
+
return "If you see this, the example is broken!";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<BodyMonospace>{result.data}</BodyMonospace>
|
|
49
|
+
);
|
|
50
|
+
}}
|
|
51
|
+
</Data>
|
|
52
|
+
</View>
|
|
53
|
+
</InterceptRequests>
|
|
54
|
+
```
|
|
@@ -65,19 +65,11 @@ import {Strut} from "@khanacademy/wonder-blocks-layout";
|
|
|
65
65
|
import Spacing from "@khanacademy/wonder-blocks-spacing";
|
|
66
66
|
import Button from "@khanacademy/wonder-blocks-button";
|
|
67
67
|
import {Server, View} from "@khanacademy/wonder-blocks-core";
|
|
68
|
-
import {Data, TrackData,
|
|
68
|
+
import {Data, TrackData, fulfillAllDataRequests} from "@khanacademy/wonder-blocks-data";
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
fulfillRequest(options) {
|
|
76
|
-
return new Promise((resolve, reject) =>
|
|
77
|
-
setTimeout(() => resolve("DATA!"), 3000),
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
70
|
+
const myPretendHandler = () => new Promise((resolve, reject) =>
|
|
71
|
+
setTimeout(() => resolve("DATA!"), 3000),
|
|
72
|
+
);
|
|
81
73
|
|
|
82
74
|
class Example extends React.Component {
|
|
83
75
|
constructor() {
|
|
@@ -87,7 +79,6 @@ class Example extends React.Component {
|
|
|
87
79
|
* for the scope of this component.
|
|
88
80
|
*/
|
|
89
81
|
this.state = {};
|
|
90
|
-
this._handler = new MyPretendHandler();
|
|
91
82
|
}
|
|
92
83
|
|
|
93
84
|
static getDerivedStateFromError(error) {
|
|
@@ -133,15 +124,16 @@ class Example extends React.Component {
|
|
|
133
124
|
const data = this.state.data
|
|
134
125
|
? JSON.stringify(this.state.data, undefined, " ")
|
|
135
126
|
: "Data requested...";
|
|
127
|
+
|
|
136
128
|
return (
|
|
137
129
|
<React.Fragment>
|
|
138
130
|
<Strut size={Spacing.small_12} />
|
|
139
131
|
<TrackData>
|
|
140
|
-
<Data handler={
|
|
141
|
-
{(
|
|
132
|
+
<Data handler={myPretendHandler} requestId="TRACK_DATA_EXAMPLE">
|
|
133
|
+
{(result) => (
|
|
142
134
|
<View>
|
|
143
|
-
<BodyMonospace>{`Loading: ${loading}`}</BodyMonospace>
|
|
144
|
-
<BodyMonospace>{`Data: ${JSON.stringify(data)}`}</BodyMonospace>
|
|
135
|
+
<BodyMonospace>{`Loading: ${result.status === "loading"}`}</BodyMonospace>
|
|
136
|
+
<BodyMonospace>{`Data: ${JSON.stringify(result.data)}`}</BodyMonospace>
|
|
145
137
|
</View>
|
|
146
138
|
)}
|
|
147
139
|
</Data>
|
|
@@ -162,12 +154,6 @@ class Example extends React.Component {
|
|
|
162
154
|
rendered tree.
|
|
163
155
|
</Body>
|
|
164
156
|
<Strut size={Spacing.small_12} />
|
|
165
|
-
<Body>
|
|
166
|
-
If you click to remount after the data appears, we'll
|
|
167
|
-
rerender with the now cached data, and above should
|
|
168
|
-
update accordingly.
|
|
169
|
-
</Body>
|
|
170
|
-
<Strut size={Spacing.small_12} />
|
|
171
157
|
<BodyMonospace>{data}</BodyMonospace>
|
|
172
158
|
</View>
|
|
173
159
|
</React.Fragment>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
|
+
|
|
3
|
+
exports[`#useSharedCache should throw if the id is 1`] = `[InvalidInputError: id must be a non-empty string]`;
|
|
4
|
+
|
|
5
|
+
exports[`#useSharedCache should throw if the id is [Function anonymous] 1`] = `[InvalidInputError: id must be a non-empty string]`;
|
|
6
|
+
|
|
7
|
+
exports[`#useSharedCache should throw if the id is 5 1`] = `[InvalidInputError: id must be a non-empty string]`;
|
|
8
|
+
|
|
9
|
+
exports[`#useSharedCache should throw if the id is null 1`] = `[InvalidInputError: id must be a non-empty string]`;
|
|
10
|
+
|
|
11
|
+
exports[`#useSharedCache should throw if the scope is 1`] = `[InvalidInputError: scope must be a non-empty string]`;
|
|
12
|
+
|
|
13
|
+
exports[`#useSharedCache should throw if the scope is [Function anonymous] 1`] = `[InvalidInputError: scope must be a non-empty string]`;
|
|
14
|
+
|
|
15
|
+
exports[`#useSharedCache should throw if the scope is 5 1`] = `[InvalidInputError: scope must be a non-empty string]`;
|
|
16
|
+
|
|
17
|
+
exports[`#useSharedCache should throw if the scope is null 1`] = `[InvalidInputError: scope must be a non-empty string]`;
|