@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/es/index.js +793 -375
  3. package/dist/index.js +1203 -523
  4. package/legacy-docs.md +3 -0
  5. package/package.json +2 -2
  6. package/src/__docs__/_overview_.stories.mdx +18 -0
  7. package/src/__docs__/_overview_graphql.stories.mdx +35 -0
  8. package/src/__docs__/_overview_ssr_.stories.mdx +185 -0
  9. package/src/__docs__/_overview_testing_.stories.mdx +123 -0
  10. package/src/__docs__/exports.clear-shared-cache.stories.mdx +20 -0
  11. package/src/__docs__/exports.data-error.stories.mdx +23 -0
  12. package/src/__docs__/exports.data-errors.stories.mdx +23 -0
  13. package/src/{components/data.md → __docs__/exports.data.stories.mdx} +15 -18
  14. package/src/__docs__/exports.fulfill-all-data-requests.stories.mdx +24 -0
  15. package/src/__docs__/exports.gql-error.stories.mdx +23 -0
  16. package/src/__docs__/exports.gql-errors.stories.mdx +20 -0
  17. package/src/__docs__/exports.gql-router.stories.mdx +29 -0
  18. package/src/__docs__/exports.has-unfulfilled-requests.stories.mdx +20 -0
  19. package/src/__docs__/exports.intercept-requests.stories.mdx +69 -0
  20. package/src/__docs__/exports.intialize-cache.stories.mdx +29 -0
  21. package/src/__docs__/exports.remove-all-from-cache.stories.mdx +24 -0
  22. package/src/__docs__/exports.remove-from-cache.stories.mdx +25 -0
  23. package/src/__docs__/exports.request-fulfillment.stories.mdx +36 -0
  24. package/src/__docs__/exports.scoped-in-memory-cache.stories.mdx +92 -0
  25. package/src/__docs__/exports.serializable-in-memory-cache.stories.mdx +112 -0
  26. package/src/__docs__/exports.status.stories.mdx +31 -0
  27. package/src/{components/track-data.md → __docs__/exports.track-data.stories.mdx} +15 -0
  28. package/src/__docs__/exports.use-cached-effect.stories.mdx +41 -0
  29. package/src/__docs__/exports.use-gql.stories.mdx +73 -0
  30. package/src/__docs__/exports.use-hydratable-effect.stories.mdx +43 -0
  31. package/src/__docs__/exports.use-server-effect.stories.mdx +38 -0
  32. package/src/__docs__/exports.use-shared-cache.stories.mdx +30 -0
  33. package/src/__docs__/exports.when-client-side.stories.mdx +33 -0
  34. package/src/__docs__/types.cached-response.stories.mdx +29 -0
  35. package/src/__docs__/types.error-options.stories.mdx +21 -0
  36. package/src/__docs__/types.gql-context.stories.mdx +20 -0
  37. package/src/__docs__/types.gql-fetch-fn.stories.mdx +24 -0
  38. package/src/__docs__/types.gql-fetch-options.stories.mdx +24 -0
  39. package/src/__docs__/types.gql-operation-type.stories.mdx +24 -0
  40. package/src/__docs__/types.gql-operation.stories.mdx +67 -0
  41. package/src/__docs__/types.response-cache.stories.mdx +33 -0
  42. package/src/__docs__/types.result.stories.mdx +39 -0
  43. package/src/__docs__/types.scoped-cache.stories.mdx +27 -0
  44. package/src/__docs__/types.valid-cache-data.stories.mdx +23 -0
  45. package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +0 -80
  46. package/src/__tests__/generated-snapshot.test.js +7 -31
  47. package/src/components/__tests__/data.test.js +160 -154
  48. package/src/components/__tests__/intercept-requests.test.js +58 -0
  49. package/src/components/data.js +22 -126
  50. package/src/components/intercept-context.js +4 -5
  51. package/src/components/intercept-requests.js +69 -0
  52. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
  53. package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
  54. package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
  55. package/src/hooks/__tests__/use-gql.test.js +1 -30
  56. package/src/hooks/__tests__/use-hydratable-effect.test.js +708 -0
  57. package/src/hooks/__tests__/use-request-interception.test.js +255 -0
  58. package/src/hooks/__tests__/use-server-effect.test.js +39 -11
  59. package/src/hooks/use-cached-effect.js +225 -0
  60. package/src/hooks/use-gql-router-context.js +50 -0
  61. package/src/hooks/use-gql.js +22 -52
  62. package/src/hooks/use-hydratable-effect.js +206 -0
  63. package/src/hooks/use-request-interception.js +51 -0
  64. package/src/hooks/use-server-effect.js +14 -7
  65. package/src/hooks/use-shared-cache.js +13 -11
  66. package/src/index.js +54 -2
  67. package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
  68. package/src/util/__tests__/merge-gql-context.test.js +74 -0
  69. package/src/util/__tests__/request-fulfillment.test.js +23 -42
  70. package/src/util/__tests__/request-tracking.test.js +26 -7
  71. package/src/util/__tests__/result-from-cache-response.test.js +19 -5
  72. package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
  73. package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
  74. package/src/util/__tests__/ssr-cache.test.js +52 -52
  75. package/src/util/abort-error.js +15 -0
  76. package/src/util/data-error.js +58 -0
  77. package/src/util/get-gql-data-from-response.js +3 -2
  78. package/src/util/gql-error.js +19 -11
  79. package/src/util/merge-gql-context.js +34 -0
  80. package/src/util/request-fulfillment.js +49 -46
  81. package/src/util/request-tracking.js +69 -15
  82. package/src/util/result-from-cache-response.js +12 -16
  83. package/src/util/scoped-in-memory-cache.js +24 -47
  84. package/src/util/serializable-in-memory-cache.js +49 -0
  85. package/src/util/ssr-cache.js +9 -8
  86. package/src/util/status.js +30 -0
  87. package/src/util/types.js +18 -1
  88. package/docs.md +0 -122
  89. package/src/components/__tests__/intercept-data.test.js +0 -63
  90. package/src/components/intercept-data.js +0 -66
  91. package/src/components/intercept-data.md +0 -51
@@ -1,9 +1,9 @@
1
1
  // @flow
2
- import {useContext, useMemo} from "react";
2
+ import {useCallback} from "react";
3
3
 
4
- import {GqlRouterContext} from "../util/gql-router-context.js";
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 = (): (<TData, TVariables: {...}, TContext: GqlContext>(
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<?TData>) => {
29
+ ) => Promise<TData>) => {
28
30
  // This hook only works if the `GqlRouter` has been used to setup context.
29
- const gqlRouterContext = useContext(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 = useMemo(
41
- () =>
42
- <TData, TVariables: {...}, TContext: GqlContext>(
43
- operation: GqlOperation<TData, TVariables>,
44
- options: GqlFetchOptions<TVariables, TContext> = Object.freeze(
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
- // Let's merge the partial context of the fetch with the
51
- // default context. We deliberately don't spread because
52
- // spreading would overwrite default context values with
53
- // undefined if the partial context includes a value explicitly
54
- // set to undefined. Instead, we use a map/reduce of keys.
55
- const mergedContext = Object.keys(context).reduce(
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 {CachedResponse, ValidCacheData} from "../util/types.js";
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
- id: string,
26
- handler: () => Promise<?TData>,
27
+ requestId: string,
28
+ handler: () => Promise<TData>,
27
29
  hydrate: boolean = true,
28
- ): ?CachedResponse<TData> => {
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>(id);
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?.(id, handler, hydrate);
47
+ maybeTrack?.(requestId, interceptedHandler, hydrate);
42
48
  }
43
49
 
44
- return cachedResult;
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 {KindError, Errors} from "@khanacademy/wonder-stuff-core";
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 KindError(
60
+ throw new DataError(
61
61
  "id must be a non-empty string",
62
- Errors.InvalidInput,
62
+ DataErrors.InvalidInput,
63
63
  );
64
64
  }
65
65
 
66
66
  if (!scope || typeof scope !== "string") {
67
- throw new KindError(
67
+ throw new DataError(
68
68
  "scope must be a non-empty string",
69
- Errors.InvalidInput,
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.useMemo(
76
- () => (value: ?TValue) =>
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
- // Update the cache.
98
- cacheValue(value);
97
+ if (value != null) {
98
+ // Update the cache.
99
+ cacheValue(value);
99
100
 
100
- // Make sure we return this value as our current value.
101
- currentValue = value;
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 InterceptData} from "./components/intercept-data.js";
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 * from "./util/gql-error.js";
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
+ });