@khanacademy/wonder-blocks-data 4.0.0 → 6.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 +31 -0
- package/dist/es/index.js +793 -375
- package/dist/index.js +1203 -523
- package/legacy-docs.md +3 -0
- package/package.json +2 -2
- package/src/__docs__/_overview_.stories.mdx +18 -0
- package/src/__docs__/_overview_graphql.stories.mdx +35 -0
- package/src/__docs__/_overview_ssr_.stories.mdx +185 -0
- package/src/__docs__/_overview_testing_.stories.mdx +123 -0
- package/src/__docs__/exports.clear-shared-cache.stories.mdx +20 -0
- package/src/__docs__/exports.data-error.stories.mdx +23 -0
- package/src/__docs__/exports.data-errors.stories.mdx +23 -0
- package/src/{components/data.md → __docs__/exports.data.stories.mdx} +15 -18
- package/src/__docs__/exports.fulfill-all-data-requests.stories.mdx +24 -0
- package/src/__docs__/exports.gql-error.stories.mdx +23 -0
- package/src/__docs__/exports.gql-errors.stories.mdx +20 -0
- package/src/__docs__/exports.gql-router.stories.mdx +29 -0
- package/src/__docs__/exports.has-unfulfilled-requests.stories.mdx +20 -0
- package/src/__docs__/exports.intercept-requests.stories.mdx +69 -0
- package/src/__docs__/exports.intialize-cache.stories.mdx +29 -0
- package/src/__docs__/exports.remove-all-from-cache.stories.mdx +24 -0
- package/src/__docs__/exports.remove-from-cache.stories.mdx +25 -0
- package/src/__docs__/exports.request-fulfillment.stories.mdx +36 -0
- package/src/__docs__/exports.scoped-in-memory-cache.stories.mdx +92 -0
- package/src/__docs__/exports.serializable-in-memory-cache.stories.mdx +112 -0
- package/src/__docs__/exports.status.stories.mdx +31 -0
- package/src/{components/track-data.md → __docs__/exports.track-data.stories.mdx} +15 -0
- package/src/__docs__/exports.use-cached-effect.stories.mdx +41 -0
- package/src/__docs__/exports.use-gql.stories.mdx +73 -0
- package/src/__docs__/exports.use-hydratable-effect.stories.mdx +43 -0
- package/src/__docs__/exports.use-server-effect.stories.mdx +38 -0
- package/src/__docs__/exports.use-shared-cache.stories.mdx +30 -0
- package/src/__docs__/exports.when-client-side.stories.mdx +33 -0
- package/src/__docs__/types.cached-response.stories.mdx +29 -0
- package/src/__docs__/types.error-options.stories.mdx +21 -0
- package/src/__docs__/types.gql-context.stories.mdx +20 -0
- package/src/__docs__/types.gql-fetch-fn.stories.mdx +24 -0
- package/src/__docs__/types.gql-fetch-options.stories.mdx +24 -0
- package/src/__docs__/types.gql-operation-type.stories.mdx +24 -0
- package/src/__docs__/types.gql-operation.stories.mdx +67 -0
- package/src/__docs__/types.response-cache.stories.mdx +33 -0
- package/src/__docs__/types.result.stories.mdx +39 -0
- package/src/__docs__/types.scoped-cache.stories.mdx +27 -0
- package/src/__docs__/types.valid-cache-data.stories.mdx +23 -0
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +0 -80
- package/src/__tests__/generated-snapshot.test.js +7 -31
- package/src/components/__tests__/data.test.js +160 -154
- package/src/components/__tests__/intercept-requests.test.js +58 -0
- package/src/components/data.js +22 -126
- package/src/components/intercept-context.js +4 -5
- package/src/components/intercept-requests.js +69 -0
- package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
- package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
- package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
- package/src/hooks/__tests__/use-gql.test.js +1 -30
- package/src/hooks/__tests__/use-hydratable-effect.test.js +708 -0
- package/src/hooks/__tests__/use-request-interception.test.js +255 -0
- package/src/hooks/__tests__/use-server-effect.test.js +39 -11
- package/src/hooks/use-cached-effect.js +225 -0
- package/src/hooks/use-gql-router-context.js +50 -0
- package/src/hooks/use-gql.js +22 -52
- package/src/hooks/use-hydratable-effect.js +206 -0
- package/src/hooks/use-request-interception.js +51 -0
- package/src/hooks/use-server-effect.js +14 -7
- package/src/hooks/use-shared-cache.js +13 -11
- package/src/index.js +54 -2
- package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
- package/src/util/__tests__/merge-gql-context.test.js +74 -0
- package/src/util/__tests__/request-fulfillment.test.js +23 -42
- package/src/util/__tests__/request-tracking.test.js +26 -7
- package/src/util/__tests__/result-from-cache-response.test.js +19 -5
- package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
- package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
- package/src/util/__tests__/ssr-cache.test.js +52 -52
- package/src/util/abort-error.js +15 -0
- package/src/util/data-error.js +58 -0
- package/src/util/get-gql-data-from-response.js +3 -2
- package/src/util/gql-error.js +19 -11
- package/src/util/merge-gql-context.js +34 -0
- package/src/util/request-fulfillment.js +49 -46
- package/src/util/request-tracking.js +69 -15
- package/src/util/result-from-cache-response.js +12 -16
- package/src/util/scoped-in-memory-cache.js +24 -47
- package/src/util/serializable-in-memory-cache.js +49 -0
- package/src/util/ssr-cache.js +9 -8
- package/src/util/status.js +30 -0
- package/src/util/types.js +18 -1
- package/docs.md +0 -122
- package/src/components/__tests__/intercept-data.test.js +0 -63
- package/src/components/intercept-data.js +0 -66
- package/src/components/intercept-data.md +0 -51
package/src/components/data.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
import {
|
|
5
|
+
useHydratableEffect,
|
|
6
|
+
// TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
|
|
7
|
+
// have fixed:
|
|
8
|
+
// https://github.com/import-js/eslint-plugin-import/issues/2073
|
|
9
|
+
// eslint-disable-next-line import/named
|
|
10
|
+
WhenClientSide,
|
|
11
|
+
} from "../hooks/use-hydratable-effect.js";
|
|
9
12
|
|
|
10
13
|
import type {Result, ValidCacheData} from "../util/types.js";
|
|
11
14
|
|
|
@@ -29,18 +32,16 @@ type Props<
|
|
|
29
32
|
* old handler result may be given. This is not a supported mode of
|
|
30
33
|
* operation.
|
|
31
34
|
*/
|
|
32
|
-
handler: () => Promise
|
|
35
|
+
handler: () => Promise<TData>,
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
|
-
*
|
|
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).
|
|
38
|
+
* How the hook should behave when rendering client-side for the first time.
|
|
40
39
|
*
|
|
41
|
-
*
|
|
40
|
+
* This controls how the hook hydrates and executes when client-side.
|
|
41
|
+
*
|
|
42
|
+
* Default is `OnClientRender.ExecuteWhenNoSuccessResult`.
|
|
42
43
|
*/
|
|
43
|
-
|
|
44
|
+
clientBehavior?: WhenClientSide,
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* When true, the children will be rendered with the existing result
|
|
@@ -49,15 +50,7 @@ type Props<
|
|
|
49
50
|
*
|
|
50
51
|
* Defaults to false.
|
|
51
52
|
*/
|
|
52
|
-
|
|
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,
|
|
53
|
+
retainResultOnChange?: boolean,
|
|
61
54
|
|
|
62
55
|
/**
|
|
63
56
|
* A function that will render the content of this component using the
|
|
@@ -76,111 +69,14 @@ const Data = <TData: ValidCacheData>({
|
|
|
76
69
|
requestId,
|
|
77
70
|
handler,
|
|
78
71
|
children,
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
alwaysRequestOnHydration,
|
|
72
|
+
retainResultOnChange = false,
|
|
73
|
+
clientBehavior = WhenClientSide.ExecuteWhenNoSuccessResult,
|
|
82
74
|
}: Props<TData>): React.Node => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
// If we have an interceptor, we need to replace the handler with one
|
|
89
|
-
// that uses the interceptor. This helper function generates a new
|
|
90
|
-
// handler.
|
|
91
|
-
const maybeInterceptedHandler = React.useMemo(() => {
|
|
92
|
-
const interceptor = interceptorMap[requestId];
|
|
93
|
-
if (interceptor == null) {
|
|
94
|
-
return handler;
|
|
95
|
-
}
|
|
96
|
-
return () => interceptor() ?? handler();
|
|
97
|
-
}, [handler, interceptorMap, requestId]);
|
|
98
|
-
|
|
99
|
-
const hydrateResult = useServerEffect(
|
|
100
|
-
requestId,
|
|
101
|
-
maybeInterceptedHandler,
|
|
102
|
-
hydrate,
|
|
103
|
-
);
|
|
104
|
-
const [currentResult, setResult] = React.useState(hydrateResult);
|
|
105
|
-
|
|
106
|
-
// Here we make sure the request still occurs client-side as needed.
|
|
107
|
-
// This is for legacy usage that expects this. Eventually we will want
|
|
108
|
-
// to deprecate.
|
|
109
|
-
React.useEffect(() => {
|
|
110
|
-
// This is here until I can do a better documentation example for
|
|
111
|
-
// the TrackData docs.
|
|
112
|
-
// istanbul ignore next
|
|
113
|
-
if (Server.isServerSide()) {
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// We don't bother with this if we have hydration data and we're not
|
|
118
|
-
// forcing a request on hydration.
|
|
119
|
-
// We don't care if these things change after the first render,
|
|
120
|
-
// so we don't want them in the inputs array.
|
|
121
|
-
if (!alwaysRequestOnHydration && hydrateResult?.data != null) {
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// If we're not hydrating a result and we're not going to render
|
|
126
|
-
// with old data until we're loaded, we want to make sure we set our
|
|
127
|
-
// result to null so that we're in the loading state.
|
|
128
|
-
if (!showOldDataWhileLoading) {
|
|
129
|
-
// Mark ourselves as loading.
|
|
130
|
-
setResult(null);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// We aren't server-side, so let's make the request.
|
|
134
|
-
// We don't need to use our built-in request fulfillment here if we
|
|
135
|
-
// don't want, but it does mean we'll share inflight requests for the
|
|
136
|
-
// same ID and the result will be in the same format as the
|
|
137
|
-
// hydrated value.
|
|
138
|
-
let cancel = false;
|
|
139
|
-
RequestFulfillment.Default.fulfill(requestId, {
|
|
140
|
-
handler: maybeInterceptedHandler,
|
|
141
|
-
})
|
|
142
|
-
.then((result) => {
|
|
143
|
-
if (cancel) {
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
setResult(result);
|
|
147
|
-
return;
|
|
148
|
-
})
|
|
149
|
-
.catch((e) => {
|
|
150
|
-
if (cancel) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* We should never get here as errors in fulfillment are part
|
|
155
|
-
* of the `then`, but if we do.
|
|
156
|
-
*/
|
|
157
|
-
// eslint-disable-next-line no-console
|
|
158
|
-
console.error(
|
|
159
|
-
`Unexpected error occurred during data fulfillment: ${e}`,
|
|
160
|
-
);
|
|
161
|
-
setResult({
|
|
162
|
-
error: typeof e === "string" ? e : e.message,
|
|
163
|
-
});
|
|
164
|
-
return;
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
return () => {
|
|
168
|
-
cancel = true;
|
|
169
|
-
};
|
|
170
|
-
// If the handler changes, we don't care. The ID is what indicates
|
|
171
|
-
// the request that should be made and folks shouldn't be changing the
|
|
172
|
-
// handler without changing the ID as well.
|
|
173
|
-
// In addition, we don't want to include hydrateResult nor
|
|
174
|
-
// alwaysRequestOnHydration as them changinng after the first pass
|
|
175
|
-
// is irrelevant.
|
|
176
|
-
// Finally, we don't want to include showOldDataWhileLoading as that
|
|
177
|
-
// changing on its own is also not relevant. It only matters if the
|
|
178
|
-
// request itself changes. All of which is to say that we only
|
|
179
|
-
// run this effect for the ID changing.
|
|
180
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
181
|
-
}, [requestId]);
|
|
182
|
-
|
|
183
|
-
return children(resultFromCachedResponse(currentResult));
|
|
75
|
+
const result = useHydratableEffect(requestId, handler, {
|
|
76
|
+
retainResultOnChange,
|
|
77
|
+
clientBehavior,
|
|
78
|
+
});
|
|
79
|
+
return children(result);
|
|
184
80
|
};
|
|
185
81
|
|
|
186
82
|
export default Data;
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import type {ValidCacheData} from "../util/types.js";
|
|
4
4
|
|
|
5
|
-
type InterceptContextData =
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
};
|
|
5
|
+
type InterceptContextData = $ReadOnlyArray<
|
|
6
|
+
(requestId: string) => ?Promise<?ValidCacheData>,
|
|
7
|
+
>;
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
10
|
* InterceptContext defines a map from request ID to interception methods.
|
|
@@ -13,6 +12,6 @@ type InterceptContextData = {
|
|
|
13
12
|
* INTERNAL USE ONLY
|
|
14
13
|
*/
|
|
15
14
|
const InterceptContext: React.Context<InterceptContextData> =
|
|
16
|
-
React.createContext<InterceptContextData>(
|
|
15
|
+
React.createContext<InterceptContextData>([]);
|
|
17
16
|
|
|
18
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;
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
2
|
|
|
3
|
-
exports[`#useSharedCache should throw if the id is 1`] = `[
|
|
3
|
+
exports[`#useSharedCache should throw if the id is 1`] = `[InvalidInputDataError: id must be a non-empty string]`;
|
|
4
4
|
|
|
5
|
-
exports[`#useSharedCache should throw if the id is [Function anonymous] 1`] = `[
|
|
5
|
+
exports[`#useSharedCache should throw if the id is [Function anonymous] 1`] = `[InvalidInputDataError: id must be a non-empty string]`;
|
|
6
6
|
|
|
7
|
-
exports[`#useSharedCache should throw if the id is 5 1`] = `[
|
|
7
|
+
exports[`#useSharedCache should throw if the id is 5 1`] = `[InvalidInputDataError: id must be a non-empty string]`;
|
|
8
8
|
|
|
9
|
-
exports[`#useSharedCache should throw if the id is null 1`] = `[
|
|
9
|
+
exports[`#useSharedCache should throw if the id is null 1`] = `[InvalidInputDataError: id must be a non-empty string]`;
|
|
10
10
|
|
|
11
|
-
exports[`#useSharedCache should throw if the scope is 1`] = `[
|
|
11
|
+
exports[`#useSharedCache should throw if the scope is 1`] = `[InvalidInputDataError: scope must be a non-empty string]`;
|
|
12
12
|
|
|
13
|
-
exports[`#useSharedCache should throw if the scope is [Function anonymous] 1`] = `[
|
|
13
|
+
exports[`#useSharedCache should throw if the scope is [Function anonymous] 1`] = `[InvalidInputDataError: scope must be a non-empty string]`;
|
|
14
14
|
|
|
15
|
-
exports[`#useSharedCache should throw if the scope is 5 1`] = `[
|
|
15
|
+
exports[`#useSharedCache should throw if the scope is 5 1`] = `[InvalidInputDataError: scope must be a non-empty string]`;
|
|
16
16
|
|
|
17
|
-
exports[`#useSharedCache should throw if the scope is null 1`] = `[
|
|
17
|
+
exports[`#useSharedCache should throw if the scope is null 1`] = `[InvalidInputDataError: scope must be a non-empty string]`;
|