@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/hooks/use-gql.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// @flow
|
|
2
|
-
import {
|
|
2
|
+
import {useCallback} from "react";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {mergeGqlContext} from "../util/merge-gql-context.js";
|
|
5
|
+
import {useGqlRouterContext} from "./use-gql-router-context.js";
|
|
5
6
|
import {getGqlDataFromResponse} from "../util/get-gql-data-from-response.js";
|
|
6
|
-
import {GqlError, GqlErrors} from "../util/gql-error.js";
|
|
7
7
|
|
|
8
8
|
import type {
|
|
9
9
|
GqlContext,
|
|
@@ -21,65 +21,35 @@ import type {
|
|
|
21
21
|
* Values in the partial context given to the returned fetch function will
|
|
22
22
|
* only be included if they have a value other than undefined.
|
|
23
23
|
*/
|
|
24
|
-
export const useGql =
|
|
24
|
+
export const useGql = <TContext: GqlContext>(
|
|
25
|
+
context: Partial<TContext> = ({}: $Shape<TContext>),
|
|
26
|
+
): (<TData, TVariables: {...}>(
|
|
25
27
|
operation: GqlOperation<TData, TVariables>,
|
|
26
28
|
options?: GqlFetchOptions<TVariables, TContext>,
|
|
27
|
-
) => Promise
|
|
29
|
+
) => Promise<TData>) => {
|
|
28
30
|
// This hook only works if the `GqlRouter` has been used to setup context.
|
|
29
|
-
const gqlRouterContext =
|
|
30
|
-
if (gqlRouterContext == null) {
|
|
31
|
-
throw new GqlError("No GqlRouter", GqlErrors.Internal);
|
|
32
|
-
}
|
|
33
|
-
const {fetch, defaultContext} = gqlRouterContext;
|
|
31
|
+
const gqlRouterContext = useGqlRouterContext(context);
|
|
34
32
|
|
|
35
33
|
// Let's memoize the gqlFetch function we create based off our context.
|
|
36
34
|
// That way, even if the context happens to change, if its values don't
|
|
37
35
|
// we give the same function instance back to our callers instead of
|
|
38
36
|
// making a new one. That then means they can safely use the return value
|
|
39
37
|
// in hooks deps without fear of it triggering extra renders.
|
|
40
|
-
const gqlFetch =
|
|
41
|
-
(
|
|
42
|
-
<TData, TVariables
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const {variables, context = {}} = options;
|
|
38
|
+
const gqlFetch = useCallback(
|
|
39
|
+
<TData, TVariables: {...}>(
|
|
40
|
+
operation: GqlOperation<TData, TVariables>,
|
|
41
|
+
options: GqlFetchOptions<TVariables, TContext> = Object.freeze({}),
|
|
42
|
+
) => {
|
|
43
|
+
const {fetch, defaultContext} = gqlRouterContext;
|
|
44
|
+
const {variables, context = {}} = options;
|
|
45
|
+
const finalContext = mergeGqlContext(defaultContext, context);
|
|
49
46
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
(acc, key) => {
|
|
57
|
-
if (context[key] !== undefined) {
|
|
58
|
-
acc[key] = context[key];
|
|
59
|
-
}
|
|
60
|
-
return acc;
|
|
61
|
-
},
|
|
62
|
-
{...defaultContext},
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
// Invoke the fetch and extract the data.
|
|
66
|
-
return fetch(operation, variables, mergedContext).then(
|
|
67
|
-
getGqlDataFromResponse,
|
|
68
|
-
(error) => {
|
|
69
|
-
// Return null if the request was aborted.
|
|
70
|
-
// The only way to detect this reliably, it seems, is to
|
|
71
|
-
// check the error name and see if it's "AbortError" (this
|
|
72
|
-
// is also what Apollo does).
|
|
73
|
-
// Even then, it's reliant on the fetch supporting aborts.
|
|
74
|
-
if (error.name === "AbortError") {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
// Need to make sure we pass other errors along.
|
|
78
|
-
throw error;
|
|
79
|
-
},
|
|
80
|
-
);
|
|
81
|
-
},
|
|
82
|
-
[fetch, defaultContext],
|
|
47
|
+
// Invoke the fetch and extract the data.
|
|
48
|
+
return fetch(operation, variables, finalContext).then(
|
|
49
|
+
getGqlDataFromResponse,
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
[gqlRouterContext],
|
|
83
53
|
);
|
|
84
54
|
return gqlFetch;
|
|
85
55
|
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import {AbortError} from "../util/abort-error.js";
|
|
5
|
+
|
|
6
|
+
import {useServerEffect} from "./use-server-effect.js";
|
|
7
|
+
import {useSharedCache} from "./use-shared-cache.js";
|
|
8
|
+
import {useCachedEffect} from "./use-cached-effect.js";
|
|
9
|
+
|
|
10
|
+
import type {Result, ValidCacheData} from "../util/types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Policies to define how a hydratable effect should behave client-side.
|
|
14
|
+
*/
|
|
15
|
+
export enum WhenClientSide {
|
|
16
|
+
// TODO(somewhatabstract, FEI-4172): Update eslint-plugin-flowtype when
|
|
17
|
+
// they've fixed https://github.com/gajus/eslint-plugin-flowtype/issues/502
|
|
18
|
+
/* eslint-disable no-undef */
|
|
19
|
+
/**
|
|
20
|
+
* The result from executing the effect server-side will not be hydrated.
|
|
21
|
+
* The effect will always be executed client-side.
|
|
22
|
+
*
|
|
23
|
+
* This should only be used if there is something else that is responsible
|
|
24
|
+
* for properly hydrating this component (for example, the action invokes
|
|
25
|
+
* Apollo which manages its own cache to ensure things render properly).
|
|
26
|
+
*/
|
|
27
|
+
DoNotHydrate,
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The result from executing the effect server-side will be hydrated.
|
|
31
|
+
* The effect will only execute client-side if there was no result to
|
|
32
|
+
* be hydrated (i.e. both error and success hydration results prevent the
|
|
33
|
+
* effect running client-side).
|
|
34
|
+
*/
|
|
35
|
+
ExecuteWhenNoResult,
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The result from executing the effect server-side will be hydrated.
|
|
39
|
+
* If the hydrated result is a success result, the effect will not be
|
|
40
|
+
* executed client-side.
|
|
41
|
+
* If the hydrated result was not a success result, or there was no
|
|
42
|
+
* hydrated result, the effect will not be executed.
|
|
43
|
+
*/
|
|
44
|
+
ExecuteWhenNoSuccessResult,
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The result from executing the effect server-side will be hydrated.
|
|
48
|
+
* The effect will always be executed client-side, regardless of the
|
|
49
|
+
* hydrated result status.
|
|
50
|
+
*/
|
|
51
|
+
AlwaysExecute,
|
|
52
|
+
/* eslint-enable no-undef */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type HydratableEffectOptions<TData: ValidCacheData> = {|
|
|
56
|
+
/**
|
|
57
|
+
* How the hook should behave when rendering client-side for the first time.
|
|
58
|
+
*
|
|
59
|
+
* This controls how the hook hydrates and executes when client-side.
|
|
60
|
+
*
|
|
61
|
+
* Default is `WhenClientSide.ExecuteWhenNoSuccessResult`.
|
|
62
|
+
*
|
|
63
|
+
* Changing this value after the first call is irrelevant as it only
|
|
64
|
+
* affects the initial render behavior.
|
|
65
|
+
*/
|
|
66
|
+
clientBehavior?: WhenClientSide,
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* When `true`, the effect will not be executed; otherwise, the effect will
|
|
70
|
+
* be executed.
|
|
71
|
+
*
|
|
72
|
+
* If this is set to `true` while the effect is still pending, the pending
|
|
73
|
+
* effect will be cancelled.
|
|
74
|
+
*
|
|
75
|
+
* Default is `false`.
|
|
76
|
+
*/
|
|
77
|
+
skip?: boolean,
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* When `true`, the effect will not reset the result to the loading status
|
|
81
|
+
* while executing if the requestId changes, instead, returning
|
|
82
|
+
* the existing result from before the change; otherwise, the result will
|
|
83
|
+
* be set to loading status.
|
|
84
|
+
*
|
|
85
|
+
* If the status is loading when the changes are made, it will remain as
|
|
86
|
+
* loading; old pending effects are discarded on changes and as such this
|
|
87
|
+
* value has no effect in that case.
|
|
88
|
+
*/
|
|
89
|
+
retainResultOnChange?: boolean,
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Callback that is invoked if the result for the given hook has changed.
|
|
93
|
+
*
|
|
94
|
+
* When defined, the hook will invoke this callback whenever it has reason
|
|
95
|
+
* to change the result and will not otherwise affect component rendering
|
|
96
|
+
* directly.
|
|
97
|
+
*
|
|
98
|
+
* When not defined, the hook will ensure the component re-renders to pick
|
|
99
|
+
* up the latest result.
|
|
100
|
+
*/
|
|
101
|
+
onResultChanged?: (result: Result<TData>) => void,
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Scope to use with the shared cache.
|
|
105
|
+
*
|
|
106
|
+
* When specified, the given scope will be used to isolate this hook's
|
|
107
|
+
* cached results. Otherwise, a shared default scope will be used.
|
|
108
|
+
*
|
|
109
|
+
* Changing this value after the first call is not supported.
|
|
110
|
+
*/
|
|
111
|
+
scope?: string,
|
|
112
|
+
|};
|
|
113
|
+
|
|
114
|
+
const DefaultScope = "useHydratableEffect";
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Hook to execute an async operation on server and client.
|
|
118
|
+
*
|
|
119
|
+
* This hook executes the given handler on the server and on the client,
|
|
120
|
+
* and, depending on the given options, can hydrate the server-side result.
|
|
121
|
+
*
|
|
122
|
+
* Results are cached on the client so they can be shared between equivalent
|
|
123
|
+
* invocations. Cache changes from one hook instance do not trigger renders
|
|
124
|
+
* in components that use the same requestID.
|
|
125
|
+
*/
|
|
126
|
+
export const useHydratableEffect = <TData: ValidCacheData>(
|
|
127
|
+
requestId: string,
|
|
128
|
+
handler: () => Promise<TData>,
|
|
129
|
+
options: HydratableEffectOptions<TData> = ({}: $Shape<
|
|
130
|
+
HydratableEffectOptions<TData>,
|
|
131
|
+
>),
|
|
132
|
+
): Result<TData> => {
|
|
133
|
+
const {
|
|
134
|
+
clientBehavior = WhenClientSide.ExecuteWhenNoSuccessResult,
|
|
135
|
+
skip = false,
|
|
136
|
+
retainResultOnChange = false,
|
|
137
|
+
onResultChanged,
|
|
138
|
+
scope = DefaultScope,
|
|
139
|
+
} = options;
|
|
140
|
+
|
|
141
|
+
// Now we instruct the server to perform the operation.
|
|
142
|
+
// When client-side, this will look up any response for hydration; it does
|
|
143
|
+
// not invoke the handler.
|
|
144
|
+
const serverResult = useServerEffect(
|
|
145
|
+
requestId,
|
|
146
|
+
|
|
147
|
+
// If we're skipped (unlikely in server worlds, but maybe),
|
|
148
|
+
// just give an aborted response.
|
|
149
|
+
skip ? () => Promise.reject(new AbortError("skipped")) : handler,
|
|
150
|
+
|
|
151
|
+
// Only hydrate if our behavior isn't telling us not to.
|
|
152
|
+
clientBehavior !== WhenClientSide.DoNotHydrate,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const getDefaultCacheValue: () => ?Result<TData> = React.useCallback(() => {
|
|
156
|
+
// If we don't have a requestId, it's our first render, the one
|
|
157
|
+
// where we hydrated. So defer to our clientBehavior value.
|
|
158
|
+
switch (clientBehavior) {
|
|
159
|
+
case WhenClientSide.DoNotHydrate:
|
|
160
|
+
case WhenClientSide.AlwaysExecute:
|
|
161
|
+
// Either we weren't hydrating at all, or we don't care
|
|
162
|
+
// if we hydrated something or not, either way, we're
|
|
163
|
+
// doing a request.
|
|
164
|
+
return null;
|
|
165
|
+
|
|
166
|
+
case WhenClientSide.ExecuteWhenNoResult:
|
|
167
|
+
// We only execute if we didn't hydrate something.
|
|
168
|
+
// So, returning the hydration result as default for our
|
|
169
|
+
// cache, will then prevent the cached effect running.
|
|
170
|
+
return serverResult;
|
|
171
|
+
|
|
172
|
+
case WhenClientSide.ExecuteWhenNoSuccessResult:
|
|
173
|
+
// We only execute if we didn't hydrate a success result.
|
|
174
|
+
if (serverResult?.status === "success") {
|
|
175
|
+
// So, returning the hydration result as default for our
|
|
176
|
+
// cache, will then prevent the cached effect running.
|
|
177
|
+
return serverResult;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
// There is no reason for this to change after the first render.
|
|
182
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
// Instead of using state, which would be local to just this hook instance,
|
|
186
|
+
// we use a shared in-memory cache.
|
|
187
|
+
useSharedCache<Result<TData>>(
|
|
188
|
+
requestId, // The key of the cached item
|
|
189
|
+
scope, // The scope of the cached items
|
|
190
|
+
getDefaultCacheValue,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// When we're client-side, we ultimately want the result from this call.
|
|
194
|
+
const clientResult = useCachedEffect(requestId, handler, {
|
|
195
|
+
skip,
|
|
196
|
+
onResultChanged,
|
|
197
|
+
retainResultOnChange,
|
|
198
|
+
scope,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// OK, now which result do we return.
|
|
202
|
+
// Well, we return the serverResult on our very first call and then
|
|
203
|
+
// the clientResult thereafter. The great thing is that after the very
|
|
204
|
+
// first call, the serverResult is going to be `null` anyway.
|
|
205
|
+
return serverResult ?? clientResult;
|
|
206
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
|
|
4
|
+
import InterceptContext from "../components/intercept-context.js";
|
|
5
|
+
import type {ValidCacheData} from "../util/types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Allow request handling to be intercepted.
|
|
9
|
+
*
|
|
10
|
+
* Hook to take a uniquely identified request handler and return a
|
|
11
|
+
* method that will support request interception from the InterceptRequest
|
|
12
|
+
* component.
|
|
13
|
+
*
|
|
14
|
+
* If you want request interception to be supported with `useServerEffect` or
|
|
15
|
+
* any client-side effect that uses the handler, call this first to generate
|
|
16
|
+
* an intercepted handler, and then invoke `useServerEffect` (or other things)
|
|
17
|
+
* with that intercepted handler.
|
|
18
|
+
*/
|
|
19
|
+
export const useRequestInterception = <TData: ValidCacheData>(
|
|
20
|
+
requestId: string,
|
|
21
|
+
handler: () => Promise<TData>,
|
|
22
|
+
): (() => Promise<TData>) => {
|
|
23
|
+
// Get the interceptors that have been registered.
|
|
24
|
+
const interceptors = React.useContext(InterceptContext);
|
|
25
|
+
|
|
26
|
+
// Now, we need to create a new handler that will check if the
|
|
27
|
+
// request is intercepted before ultimately calling the original handler
|
|
28
|
+
// if nothing intercepted it.
|
|
29
|
+
// We memoize this so that it only changes if something related to it
|
|
30
|
+
// changes.
|
|
31
|
+
const interceptedHandler = React.useCallback((): Promise<TData> => {
|
|
32
|
+
// Call the interceptors from closest to furthest.
|
|
33
|
+
// If one returns a non-null result, then we keep that.
|
|
34
|
+
const interceptResponse = interceptors.reduceRight(
|
|
35
|
+
(prev, interceptor) => {
|
|
36
|
+
if (prev != null) {
|
|
37
|
+
return prev;
|
|
38
|
+
}
|
|
39
|
+
return interceptor(requestId);
|
|
40
|
+
},
|
|
41
|
+
null,
|
|
42
|
+
);
|
|
43
|
+
// If nothing intercepted this request, invoke the original handler.
|
|
44
|
+
// NOTE: We can't guarantee all interceptors return the same type
|
|
45
|
+
// as our handler, so how can flow know? Let's just suppress that.
|
|
46
|
+
// $FlowFixMe[incompatible-return]
|
|
47
|
+
return interceptResponse ?? handler();
|
|
48
|
+
}, [handler, interceptors, requestId]);
|
|
49
|
+
|
|
50
|
+
return interceptedHandler;
|
|
51
|
+
};
|
|
@@ -3,8 +3,10 @@ import {Server} from "@khanacademy/wonder-blocks-core";
|
|
|
3
3
|
import {useContext} from "react";
|
|
4
4
|
import {TrackerContext} from "../util/request-tracking.js";
|
|
5
5
|
import {SsrCache} from "../util/ssr-cache.js";
|
|
6
|
+
import {resultFromCachedResponse} from "../util/result-from-cache-response.js";
|
|
7
|
+
import {useRequestInterception} from "./use-request-interception.js";
|
|
6
8
|
|
|
7
|
-
import type {
|
|
9
|
+
import type {Result, ValidCacheData} from "../util/types.js";
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Hook to perform an asynchronous action during server-side rendering.
|
|
@@ -22,24 +24,29 @@ import type {CachedResponse, ValidCacheData} from "../util/types.js";
|
|
|
22
24
|
* The asynchronous action is never invoked on the client-side.
|
|
23
25
|
*/
|
|
24
26
|
export const useServerEffect = <TData: ValidCacheData>(
|
|
25
|
-
|
|
26
|
-
handler: () => Promise
|
|
27
|
+
requestId: string,
|
|
28
|
+
handler: () => Promise<TData>,
|
|
27
29
|
hydrate: boolean = true,
|
|
28
|
-
): ?
|
|
30
|
+
): ?Result<TData> => {
|
|
31
|
+
// Plug in to the request interception framework for code that wants
|
|
32
|
+
// to use that.
|
|
33
|
+
const interceptedHandler = useRequestInterception(requestId, handler);
|
|
34
|
+
|
|
29
35
|
// If we're server-side or hydrating, we'll have a cached entry to use.
|
|
30
36
|
// So we get that and use it to initialize our state.
|
|
31
37
|
// This works in both hydration and SSR because the very first call to
|
|
32
38
|
// this will have cached data in those cases as it will be present on the
|
|
33
39
|
// initial render - and subsequent renders on the client it will be null.
|
|
34
|
-
const cachedResult = SsrCache.Default.getEntry<TData>(
|
|
40
|
+
const cachedResult = SsrCache.Default.getEntry<TData>(requestId);
|
|
35
41
|
|
|
36
42
|
// We only track data requests when we are server-side and we don't
|
|
37
43
|
// already have a result, as given by the cachedData (which is also the
|
|
38
44
|
// initial value for the result state).
|
|
39
45
|
const maybeTrack = useContext(TrackerContext);
|
|
40
46
|
if (cachedResult == null && Server.isServerSide()) {
|
|
41
|
-
maybeTrack?.(
|
|
47
|
+
maybeTrack?.(requestId, interceptedHandler, hydrate);
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
// A null result means there was no result to hydrate.
|
|
51
|
+
return cachedResult == null ? null : resultFromCachedResponse(cachedResult);
|
|
45
52
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
import * as React from "react";
|
|
3
|
-
import {
|
|
3
|
+
import {DataError, DataErrors} from "../util/data-error.js";
|
|
4
4
|
import {ScopedInMemoryCache} from "../util/scoped-in-memory-cache.js";
|
|
5
5
|
import type {ValidCacheData} from "../util/types.js";
|
|
6
6
|
|
|
@@ -57,23 +57,23 @@ export const useSharedCache = <TValue: ValidCacheData>(
|
|
|
57
57
|
): [?TValue, CacheValueFn<TValue>] => {
|
|
58
58
|
// Verify arguments.
|
|
59
59
|
if (!id || typeof id !== "string") {
|
|
60
|
-
throw new
|
|
60
|
+
throw new DataError(
|
|
61
61
|
"id must be a non-empty string",
|
|
62
|
-
|
|
62
|
+
DataErrors.InvalidInput,
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
if (!scope || typeof scope !== "string") {
|
|
67
|
-
throw new
|
|
67
|
+
throw new DataError(
|
|
68
68
|
"scope must be a non-empty string",
|
|
69
|
-
|
|
69
|
+
DataErrors.InvalidInput,
|
|
70
70
|
);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Memoize our APIs.
|
|
74
74
|
// This one allows callers to set or replace the cached value.
|
|
75
|
-
const cacheValue = React.
|
|
76
|
-
(
|
|
75
|
+
const cacheValue = React.useCallback(
|
|
76
|
+
(value: ?TValue) =>
|
|
77
77
|
value == null
|
|
78
78
|
? cache.purge(scope, id)
|
|
79
79
|
: cache.set(scope, id, value),
|
|
@@ -94,11 +94,13 @@ export const useSharedCache = <TValue: ValidCacheData>(
|
|
|
94
94
|
const value =
|
|
95
95
|
typeof initialValue === "function" ? initialValue() : initialValue;
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
if (value != null) {
|
|
98
|
+
// Update the cache.
|
|
99
|
+
cacheValue(value);
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
// Make sure we return this value as our current value.
|
|
102
|
+
currentValue = value;
|
|
103
|
+
}
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
// Now we have everything, let's return it.
|
package/src/index.js
CHANGED
|
@@ -10,15 +10,33 @@ import type {
|
|
|
10
10
|
} from "./util/types.js";
|
|
11
11
|
|
|
12
12
|
export type {
|
|
13
|
+
ErrorOptions,
|
|
13
14
|
ResponseCache,
|
|
14
15
|
CachedResponse,
|
|
15
16
|
Result,
|
|
16
17
|
ScopedCache,
|
|
18
|
+
ValidCacheData,
|
|
17
19
|
} from "./util/types.js";
|
|
18
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Initialize the hydration cache.
|
|
23
|
+
*
|
|
24
|
+
* @param {ResponseCache} source The cache content to use for initializing the
|
|
25
|
+
* cache.
|
|
26
|
+
* @throws {Error} If the cache is already initialized.
|
|
27
|
+
*/
|
|
19
28
|
export const initializeCache = (source: ResponseCache): void =>
|
|
20
29
|
SsrCache.Default.initialize(source);
|
|
21
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Fulfill all tracked data requests.
|
|
33
|
+
*
|
|
34
|
+
* This is for use with the `TrackData` component during server-side rendering.
|
|
35
|
+
*
|
|
36
|
+
* @throws {Error} If executed outside of server-side rendering.
|
|
37
|
+
* @returns {Promise<void>} A promise that resolves when all tracked requests
|
|
38
|
+
* have been fulfilled.
|
|
39
|
+
*/
|
|
22
40
|
export const fulfillAllDataRequests = (): Promise<ResponseCache> => {
|
|
23
41
|
if (!Server.isServerSide()) {
|
|
24
42
|
return Promise.reject(
|
|
@@ -28,6 +46,15 @@ export const fulfillAllDataRequests = (): Promise<ResponseCache> => {
|
|
|
28
46
|
return RequestTracker.Default.fulfillTrackedRequests();
|
|
29
47
|
};
|
|
30
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Indicate if there are unfulfilled tracked requests.
|
|
51
|
+
*
|
|
52
|
+
* This is used in conjunction with `TrackData`.
|
|
53
|
+
*
|
|
54
|
+
* @throws {Error} If executed outside of server-side rendering.
|
|
55
|
+
* @returns {boolean} `true` if there are unfulfilled tracked requests;
|
|
56
|
+
* otherwise, `false`.
|
|
57
|
+
*/
|
|
31
58
|
export const hasUnfulfilledRequests = (): boolean => {
|
|
32
59
|
if (!Server.isServerSide()) {
|
|
33
60
|
throw new Error("Data requests are not tracked when client-side");
|
|
@@ -35,9 +62,21 @@ export const hasUnfulfilledRequests = (): boolean => {
|
|
|
35
62
|
return RequestTracker.Default.hasUnfulfilledRequests;
|
|
36
63
|
};
|
|
37
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Remove the request identified from the cached hydration responses.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} id The request ID of the response to remove from the cache.
|
|
69
|
+
*/
|
|
38
70
|
export const removeFromCache = (id: string): boolean =>
|
|
39
71
|
SsrCache.Default.remove(id);
|
|
40
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Remove all cached hydration responses that match the given predicate.
|
|
75
|
+
*
|
|
76
|
+
* @param {(id: string) => boolean} [predicate] The predicate to match against
|
|
77
|
+
* the cached hydration responses. If no predicate is provided, all cached
|
|
78
|
+
* hydration responses will be removed.
|
|
79
|
+
*/
|
|
41
80
|
export const removeAllFromCache = (
|
|
42
81
|
predicate?: (
|
|
43
82
|
key: string,
|
|
@@ -47,15 +86,28 @@ export const removeAllFromCache = (
|
|
|
47
86
|
|
|
48
87
|
export {default as TrackData} from "./components/track-data.js";
|
|
49
88
|
export {default as Data} from "./components/data.js";
|
|
50
|
-
export {default as
|
|
89
|
+
export {default as InterceptRequests} from "./components/intercept-requests.js";
|
|
90
|
+
export {DataError, DataErrors} from "./util/data-error.js";
|
|
51
91
|
export {useServerEffect} from "./hooks/use-server-effect.js";
|
|
92
|
+
export {useCachedEffect} from "./hooks/use-cached-effect.js";
|
|
52
93
|
export {useSharedCache, clearSharedCache} from "./hooks/use-shared-cache.js";
|
|
94
|
+
export {
|
|
95
|
+
useHydratableEffect,
|
|
96
|
+
// TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
|
|
97
|
+
// have fixed:
|
|
98
|
+
// https://github.com/import-js/eslint-plugin-import/issues/2073
|
|
99
|
+
// eslint-disable-next-line import/named
|
|
100
|
+
WhenClientSide,
|
|
101
|
+
} from "./hooks/use-hydratable-effect.js";
|
|
53
102
|
export {ScopedInMemoryCache} from "./util/scoped-in-memory-cache.js";
|
|
103
|
+
export {SerializableInMemoryCache} from "./util/serializable-in-memory-cache.js";
|
|
104
|
+
export {RequestFulfillment} from "./util/request-fulfillment.js";
|
|
105
|
+
export {Status} from "./util/status.js";
|
|
54
106
|
|
|
55
107
|
// GraphQL
|
|
56
108
|
export {GqlRouter} from "./components/gql-router.js";
|
|
57
109
|
export {useGql} from "./hooks/use-gql.js";
|
|
58
|
-
export
|
|
110
|
+
export {GqlError, GqlErrors} from "./util/gql-error.js";
|
|
59
111
|
export type {
|
|
60
112
|
GqlContext,
|
|
61
113
|
GqlOperation,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
|
+
|
|
3
|
+
exports[`SerializableInMemoryCache #set should throw if the id is 1`] = `"id must be non-empty string"`;
|
|
4
|
+
|
|
5
|
+
exports[`SerializableInMemoryCache #set should throw if the id is [Function anonymous] 1`] = `"id must be non-empty string"`;
|
|
6
|
+
|
|
7
|
+
exports[`SerializableInMemoryCache #set should throw if the id is 5 1`] = `"id must be non-empty string"`;
|
|
8
|
+
|
|
9
|
+
exports[`SerializableInMemoryCache #set should throw if the id is null 1`] = `"id must be non-empty string"`;
|
|
10
|
+
|
|
11
|
+
exports[`SerializableInMemoryCache #set should throw if the scope is 1`] = `"scope must be non-empty string"`;
|
|
12
|
+
|
|
13
|
+
exports[`SerializableInMemoryCache #set should throw if the scope is [Function anonymous] 1`] = `"scope must be non-empty string"`;
|
|
14
|
+
|
|
15
|
+
exports[`SerializableInMemoryCache #set should throw if the scope is 5 1`] = `"scope must be non-empty string"`;
|
|
16
|
+
|
|
17
|
+
exports[`SerializableInMemoryCache #set should throw if the scope is null 1`] = `"scope must be non-empty string"`;
|
|
18
|
+
|
|
19
|
+
exports[`SerializableInMemoryCache #set should throw if the value is a function 1`] = `"value must be a non-function value"`;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {mergeGqlContext} from "../merge-gql-context.js";
|
|
3
|
+
|
|
4
|
+
describe("#mergeGqlContext", () => {
|
|
5
|
+
it("should combine the default context with the given overrides", () => {
|
|
6
|
+
// Arrange
|
|
7
|
+
const baseContext = {
|
|
8
|
+
foo: "bar",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// Act
|
|
12
|
+
const result = mergeGqlContext<any>(baseContext, {
|
|
13
|
+
fiz: "baz",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Assert
|
|
17
|
+
expect(result).toStrictEqual({
|
|
18
|
+
foo: "bar",
|
|
19
|
+
fiz: "baz",
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should overwrite values in the default context with the given overrides", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const baseContext = {
|
|
26
|
+
foo: "bar",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Act
|
|
30
|
+
const result = mergeGqlContext<any>(baseContext, {
|
|
31
|
+
foo: "boo",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Assert
|
|
35
|
+
expect(result).toStrictEqual({
|
|
36
|
+
foo: "boo",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should not overwrite values in the default context with undefined values in the given overrides", () => {
|
|
41
|
+
// Arrange
|
|
42
|
+
const baseContext = {
|
|
43
|
+
foo: "bar",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
const result = mergeGqlContext<any>(baseContext, {
|
|
48
|
+
foo: undefined,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Assert
|
|
52
|
+
expect(result).toStrictEqual({
|
|
53
|
+
foo: "bar",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should delete values in the default context when the value is null in the given overrides", () => {
|
|
58
|
+
// Arrange
|
|
59
|
+
const baseContext = {
|
|
60
|
+
foo: "bar",
|
|
61
|
+
fiz: "baz",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Act
|
|
65
|
+
const result = mergeGqlContext<any>(baseContext, {
|
|
66
|
+
fiz: null,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(result).toStrictEqual({
|
|
71
|
+
foo: "bar",
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|