@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,11 +1,14 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
3
 
4
- import {Server} from "@khanacademy/wonder-blocks-core";
5
- import {RequestFulfillment} from "../util/request-fulfillment.js";
6
- import InterceptContext from "./intercept-context.js";
7
- import {useServerEffect} from "../hooks/use-server-effect.js";
8
- import {resultFromCachedResponse} from "../util/result-from-cache-response.js";
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<?TData>,
35
+ handler: () => Promise<TData>,
33
36
 
34
37
  /**
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).
38
+ * How the hook should behave when rendering client-side for the first time.
40
39
  *
41
- * Defaults to true.
40
+ * This controls how the hook hydrates and executes when client-side.
41
+ *
42
+ * Default is `OnClientRender.ExecuteWhenNoSuccessResult`.
42
43
  */
43
- hydrate?: boolean,
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
- 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,
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
- hydrate,
80
- showOldDataWhileLoading,
81
- alwaysRequestOnHydration,
72
+ retainResultOnChange = false,
73
+ clientBehavior = WhenClientSide.ExecuteWhenNoSuccessResult,
82
74
  }: Props<TData>): React.Node => {
83
- // Lookup to see if there's an interceptor for the handler.
84
- // If we have one, we need to replace the handler with one that
85
- // uses the interceptor.
86
- const interceptorMap = React.useContext(InterceptContext);
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
- [id: string]: <TData: ValidCacheData>() => ?Promise<?TData>,
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`] = `[InvalidInputError: id must be a non-empty string]`;
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`] = `[InvalidInputError: id must be a non-empty string]`;
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`] = `[InvalidInputError: id must be a non-empty string]`;
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`] = `[InvalidInputError: id must be a non-empty string]`;
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`] = `[InvalidInputError: scope must be a non-empty string]`;
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`] = `[InvalidInputError: scope must be a non-empty string]`;
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`] = `[InvalidInputError: scope must be a non-empty string]`;
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`] = `[InvalidInputError: scope must be a non-empty string]`;
17
+ exports[`#useSharedCache should throw if the scope is null 1`] = `[InvalidInputDataError: scope must be a non-empty string]`;