@khanacademy/wonder-blocks-data 7.0.1 → 8.0.2

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 (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/es/index.js +286 -107
  3. package/dist/index.js +1089 -713
  4. package/package.json +1 -1
  5. package/src/__docs__/_overview_ssr_.stories.mdx +13 -13
  6. package/src/__docs__/exports.abort-inflight-requests.stories.mdx +20 -0
  7. package/src/__docs__/exports.data.stories.mdx +3 -3
  8. package/src/__docs__/{exports.fulfill-all-data-requests.stories.mdx → exports.fetch-tracked-requests.stories.mdx} +5 -5
  9. package/src/__docs__/exports.get-gql-request-id.stories.mdx +24 -0
  10. package/src/__docs__/{exports.has-unfulfilled-requests.stories.mdx → exports.has-tracked-requests-to-be-fetched.stories.mdx} +4 -4
  11. package/src/__docs__/exports.intialize-hydration-cache.stories.mdx +29 -0
  12. package/src/__docs__/exports.purge-caches.stories.mdx +23 -0
  13. package/src/__docs__/{exports.remove-all-from-cache.stories.mdx → exports.purge-hydration-cache.stories.mdx} +4 -4
  14. package/src/__docs__/{exports.clear-shared-cache.stories.mdx → exports.purge-shared-cache.stories.mdx} +4 -4
  15. package/src/__docs__/exports.track-data.stories.mdx +4 -4
  16. package/src/__docs__/exports.use-cached-effect.stories.mdx +7 -4
  17. package/src/__docs__/exports.use-gql.stories.mdx +1 -33
  18. package/src/__docs__/exports.use-server-effect.stories.mdx +1 -1
  19. package/src/__docs__/exports.use-shared-cache.stories.mdx +2 -2
  20. package/src/__docs__/types.fetch-policy.stories.mdx +44 -0
  21. package/src/__docs__/types.response-cache.stories.mdx +1 -1
  22. package/src/__tests__/generated-snapshot.test.js +5 -5
  23. package/src/components/__tests__/data.test.js +2 -6
  24. package/src/hooks/__tests__/use-cached-effect.test.js +341 -100
  25. package/src/hooks/__tests__/use-hydratable-effect.test.js +15 -9
  26. package/src/hooks/__tests__/use-shared-cache.test.js +6 -6
  27. package/src/hooks/use-cached-effect.js +169 -93
  28. package/src/hooks/use-hydratable-effect.js +8 -1
  29. package/src/hooks/use-shared-cache.js +2 -2
  30. package/src/index.js +14 -78
  31. package/src/util/__tests__/get-gql-request-id.test.js +74 -0
  32. package/src/util/__tests__/graphql-document-node-parser.test.js +542 -0
  33. package/src/util/__tests__/hydration-cache-api.test.js +35 -0
  34. package/src/util/__tests__/purge-caches.test.js +29 -0
  35. package/src/util/__tests__/request-api.test.js +188 -0
  36. package/src/util/__tests__/request-fulfillment.test.js +42 -0
  37. package/src/util/__tests__/ssr-cache.test.js +58 -60
  38. package/src/util/__tests__/to-gql-operation.test.js +42 -0
  39. package/src/util/data-error.js +6 -0
  40. package/src/util/get-gql-request-id.js +50 -0
  41. package/src/util/graphql-document-node-parser.js +133 -0
  42. package/src/util/graphql-types.js +30 -0
  43. package/src/util/hydration-cache-api.js +28 -0
  44. package/src/util/purge-caches.js +15 -0
  45. package/src/util/request-api.js +66 -0
  46. package/src/util/request-fulfillment.js +32 -12
  47. package/src/util/request-tracking.js +1 -1
  48. package/src/util/ssr-cache.js +13 -31
  49. package/src/util/to-gql-operation.js +44 -0
  50. package/src/util/types.js +31 -0
  51. package/src/__docs__/exports.intialize-cache.stories.mdx +0 -29
  52. package/src/__docs__/exports.remove-from-cache.stories.mdx +0 -25
  53. package/src/__docs__/exports.request-fulfillment.stories.mdx +0 -36
@@ -1,6 +1,7 @@
1
1
  // @flow
2
2
  import * as React from "react";
3
3
  import {useForceUpdate} from "@khanacademy/wonder-blocks-core";
4
+ import {DataError, DataErrors} from "../util/data-error.js";
4
5
 
5
6
  import {RequestFulfillment} from "../util/request-fulfillment.js";
6
7
  import {Status} from "../util/status.js";
@@ -10,7 +11,21 @@ import {useRequestInterception} from "./use-request-interception.js";
10
11
 
11
12
  import type {Result, ValidCacheData} from "../util/types.js";
12
13
 
14
+ // TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
15
+ // have fixed:
16
+ // https://github.com/import-js/eslint-plugin-import/issues/2073
17
+ // eslint-disable-next-line import/named
18
+ import {FetchPolicy} from "../util/types.js";
19
+
13
20
  type CachedEffectOptions<TData: ValidCacheData> = {|
21
+ /**
22
+ * The policy to use when determining how to retrieve the request data from
23
+ * cache and network.
24
+ *
25
+ * Defaults to `FetchPolicy.CacheBeforeNetwork`.
26
+ */
27
+ fetchPolicy?: FetchPolicy,
28
+
14
29
  /**
15
30
  * When `true`, the effect will not be executed; otherwise, the effect will
16
31
  * be executed.
@@ -81,8 +96,9 @@ export const useCachedEffect = <TData: ValidCacheData>(
81
96
  options: CachedEffectOptions<TData> = ({}: $Shape<
82
97
  CachedEffectOptions<TData>,
83
98
  >),
84
- ): Result<TData> => {
99
+ ): [Result<TData>, () => void] => {
85
100
  const {
101
+ fetchPolicy = FetchPolicy.CacheBeforeNetwork,
86
102
  skip: hardSkip = false,
87
103
  retainResultOnChange = false,
88
104
  onResultChanged,
@@ -104,107 +120,166 @@ export const useCachedEffect = <TData: ValidCacheData>(
104
120
  // that all calls when the request is in-flight will update once that
105
121
  // request is done, we want the cache to be empty until that point.
106
122
  );
107
-
108
- // Build a function that will update the cache and either invoke the
109
- // callback provided in options, or force an update.
110
123
  const forceUpdate = useForceUpdate();
111
- const setCacheAndNotify = React.useCallback(
112
- (value: Result<TData>) => {
113
- setMostRecentResult(value);
114
-
115
- // If our caller provided a cacheUpdated callback, we use that.
116
- // Otherwise, we toggle our little state update.
117
- if (onResultChanged != null) {
118
- onResultChanged(value);
119
- } else {
120
- forceUpdate();
124
+ // For the NetworkOnly fetch policy, we ignore the cached value.
125
+ // So we need somewhere else to store the network value.
126
+ const networkResultRef = React.useRef();
127
+
128
+ // Set up the function that will do the fetching.
129
+ const currentRequestRef = React.useRef();
130
+ const fetchRequest = React.useMemo(() => {
131
+ // We aren't using useCallback here because we need to make sure that
132
+ // if we are rememo-izing, we cancel any inflight request for the old
133
+ // callback.
134
+ currentRequestRef.current?.cancel();
135
+ currentRequestRef.current = null;
136
+ networkResultRef.current = null;
137
+
138
+ const fetchFn = () => {
139
+ if (fetchPolicy === FetchPolicy.CacheOnly) {
140
+ throw new DataError(
141
+ "Cannot fetch with CacheOnly policy",
142
+ DataErrors.NotAllowed,
143
+ );
121
144
  }
122
- },
123
- [setMostRecentResult, onResultChanged, forceUpdate],
124
- );
145
+ // We use our request fulfillment here so that in-flight
146
+ // requests are shared. In order to ensure that we don't share
147
+ // in-flight requests for different scopes, we add the scope to the
148
+ // requestId.
149
+ // We do this as a courtesy to simplify usage in sandboxed
150
+ // uses like storybook where we want each story to perform their
151
+ // own requests from scratch and not share inflight requests across
152
+ // stories.
153
+ // Since this only occurs here, nothing else will care about this
154
+ // change except the request tracking.
155
+ const request = RequestFulfillment.Default.fulfill(
156
+ `${requestId}|${scope}`,
157
+ {
158
+ handler: interceptedHandler,
159
+ },
160
+ );
161
+
162
+ if (request === currentRequestRef.current?.request) {
163
+ // The request inflight is the same, so do nothing.
164
+ // NOTE: Perhaps if invoked via a refetch, we will want to
165
+ // override this behavior and force a new request?
166
+ return;
167
+ }
168
+
169
+ // Clear the last network result.
170
+ networkResultRef.current = null;
171
+
172
+ // Cancel the previous request.
173
+ currentRequestRef.current?.cancel();
174
+
175
+ // TODO(somewhatabstract, FEI-4276):
176
+ // Until our RequestFulfillment API supports cancelling/aborting, we
177
+ // will have to do it.
178
+ let cancel = false;
179
+
180
+ // NOTE: Our request fulfillment handles the error cases here.
181
+ // Catching shouldn't serve a purpose.
182
+ // eslint-disable-next-line promise/catch-or-return
183
+ request.then((result) => {
184
+ currentRequestRef.current = null;
185
+ if (cancel) {
186
+ // We don't modify our result if the request was cancelled
187
+ // as it means that this hook no longer cares about that old
188
+ // request.
189
+ return;
190
+ }
191
+
192
+ // Now we need to update the cache and notify or force a rerender.
193
+ setMostRecentResult(result);
194
+ networkResultRef.current = result;
195
+
196
+ if (onResultChanged != null) {
197
+ // If we have a callback, call it to let our caller know we
198
+ // got a result.
199
+ onResultChanged(result);
200
+ } else {
201
+ // If there's no callback, and this is using cache in some
202
+ // capacity, just force a rerender.
203
+ forceUpdate();
204
+ }
205
+ return; // Shut up eslint always-return rule.
206
+ });
207
+
208
+ currentRequestRef.current = {
209
+ requestId,
210
+ request,
211
+ cancel() {
212
+ cancel = true;
213
+ RequestFulfillment.Default.abort(requestId);
214
+ },
215
+ };
216
+ };
217
+
218
+ // Now we can return the new fetch function.
219
+ return fetchFn;
220
+
221
+ // We deliberately ignore the handler here because we want folks to use
222
+ // interceptor functions inline in props for simplicity. This is OK
223
+ // since changing the handler without changing the requestId doesn't
224
+ // really make sense - the same requestId should be handled the same as
225
+ // each other.
226
+ // eslint-disable-next-line react-hooks/exhaustive-deps
227
+ }, [
228
+ requestId,
229
+ onResultChanged,
230
+ forceUpdate,
231
+ setMostRecentResult,
232
+ fetchPolicy,
233
+ ]);
125
234
 
126
235
  // We need to trigger a re-render when the request ID changes as that
127
- // indicates its a different request. We don't default the current id as
128
- // this is a proxy for the first render, where we will make the request
129
- // if we don't already have a cached value.
130
- const requestIdRef = React.useRef();
131
- const previousRequestId = requestIdRef.current;
132
-
133
- // Calculate our soft skip state.
134
- // Soft skip changes are things that should skip the effect if something
135
- // else triggers the effect to run, but should not itself trigger the effect
136
- // (which would cancel a previous invocation).
137
- const softSkip = React.useMemo(() => {
138
- if (requestId === previousRequestId) {
139
- // If the requestId is unchanged, it means we already rendered at
140
- // least once and so we already made the request at least once. So
141
- // we can bail out right here.
142
- return true;
236
+ // indicates its a different request.
237
+ const requestIdRef = React.useRef(requestId);
238
+
239
+ // Calculate if we want to fetch the result or not.
240
+ // If this is true, we will do a new fetch, cancelling the previous fetch
241
+ // if there is one inflight.
242
+ const shouldFetch = React.useMemo(() => {
243
+ if (hardSkip) {
244
+ // We don't fetch if we've been told to hard skip.
245
+ return false;
143
246
  }
144
247
 
145
- // If we already have a cached value, we're going to skip.
146
- if (mostRecentResult != null) {
147
- return true;
248
+ switch (fetchPolicy) {
249
+ case FetchPolicy.CacheOnly:
250
+ // Don't want to do a network request if we're only
251
+ // interested in the cache.
252
+ return false;
253
+
254
+ case FetchPolicy.CacheBeforeNetwork:
255
+ // If we don't have a cached value or this is a new requestId,
256
+ // then we need to fetch.
257
+ return (
258
+ mostRecentResult == null ||
259
+ requestId !== requestIdRef.current
260
+ );
261
+
262
+ case FetchPolicy.CacheAndNetwork:
263
+ case FetchPolicy.NetworkOnly:
264
+ // We don't care about the cache. If we don't have a network
265
+ // result, then we need to fetch one.
266
+ return networkResultRef.current == null;
148
267
  }
268
+ }, [requestId, mostRecentResult, fetchPolicy, hardSkip]);
149
269
 
150
- return false;
151
- }, [requestId, previousRequestId, mostRecentResult]);
270
+ // Let's make sure our ref is set to the most recent requestId.
271
+ requestIdRef.current = requestId;
152
272
 
153
- // So now we make sure the client-side request happens per our various
154
- // options.
155
273
  React.useEffect(() => {
156
- let cancel = false;
157
-
158
- // We don't do anything if we've been told to hard skip (a hard skip
159
- // means we should cancel the previous request and is therefore a
160
- // dependency on that), or we have determined we have already done
161
- // enough and can soft skip (a soft skip doesn't trigger the request
162
- // to re-run; we don't want to cancel the in progress effect if we're
163
- // soft skipping.
164
- if (hardSkip || softSkip) {
274
+ if (!shouldFetch) {
165
275
  return;
166
276
  }
167
-
168
- // If we got here, we're going to perform the request.
169
- // Let's make sure our ref is set to the most recent requestId.
170
- requestIdRef.current = requestId;
171
-
172
- // OK, we've done all our checks and things. It's time to make the
173
- // request. We use our request fulfillment here so that in-flight
174
- // requests are shared.
175
- // NOTE: Our request fulfillment handles the error cases here.
176
- // Catching shouldn't serve a purpose.
177
- // eslint-disable-next-line promise/catch-or-return
178
- RequestFulfillment.Default.fulfill(requestId, {
179
- handler: interceptedHandler,
180
- }).then((result) => {
181
- if (cancel) {
182
- // We don't modify our result if an earlier effect was
183
- // cancelled as it means that this hook no longer cares about
184
- // that old request.
185
- return;
186
- }
187
-
188
- setCacheAndNotify(result);
189
- return; // Shut up eslint always-return rule.
190
- });
191
-
277
+ fetchRequest();
192
278
  return () => {
193
- // TODO(somewhatabstract, FEI-4276): Eventually, we will want to be
194
- // able abort in-flight requests, but for now, we don't have that.
195
- // (Of course, we will only want to abort them if no one is waiting
196
- // on them)
197
- // For now, we just block cancelled requests from changing our
198
- // cache.
199
- cancel = true;
279
+ currentRequestRef.current?.cancel();
280
+ currentRequestRef.current = null;
200
281
  };
201
- // We only want to run this effect if the requestId, or skip values
202
- // change. These are the only two things that should affect the
203
- // cancellation of a pending request. We do not update if the handler
204
- // changes, in order to simplify the API - otherwise, callers would
205
- // not be able to use inline functions with this hook.
206
- // eslint-disable-next-line react-hooks/exhaustive-deps
207
- }, [hardSkip, requestId]);
282
+ }, [shouldFetch, fetchRequest]);
208
283
 
209
284
  // We track the last result we returned in order to support the
210
285
  // "retainResultOnChange" option.
@@ -215,11 +290,12 @@ export const useCachedEffect = <TData: ValidCacheData>(
215
290
 
216
291
  // Loading is a transient state, so we only use it here; it's not something
217
292
  // we cache.
218
- const result = React.useMemo(
219
- () => mostRecentResult ?? loadingResult,
220
- [mostRecentResult, loadingResult],
221
- );
293
+ const result =
294
+ (fetchPolicy === FetchPolicy.NetworkOnly
295
+ ? networkResultRef.current
296
+ : mostRecentResult) ?? loadingResult;
222
297
  lastResultAgnosticOfIdRef.current = result;
223
298
 
224
- return result;
299
+ // We return the result and a function for triggering a refetch.
300
+ return [result, fetchRequest];
225
301
  };
@@ -5,6 +5,11 @@ import {useServerEffect} from "./use-server-effect.js";
5
5
  import {useSharedCache} from "./use-shared-cache.js";
6
6
  import {useCachedEffect} from "./use-cached-effect.js";
7
7
 
8
+ // TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
9
+ // have fixed:
10
+ // https://github.com/import-js/eslint-plugin-import/issues/2073
11
+ // eslint-disable-next-line import/named
12
+ import {FetchPolicy} from "../util/types.js";
8
13
  import type {Result, ValidCacheData} from "../util/types.js";
9
14
 
10
15
  /**
@@ -191,11 +196,13 @@ export const useHydratableEffect = <TData: ValidCacheData>(
191
196
  );
192
197
 
193
198
  // When we're client-side, we ultimately want the result from this call.
194
- const clientResult = useCachedEffect(requestId, handler, {
199
+ const [clientResult] = useCachedEffect(requestId, handler, {
195
200
  skip,
196
201
  onResultChanged,
197
202
  retainResultOnChange,
198
203
  scope,
204
+ // Be explicit about our fetch policy for clarity.
205
+ fetchPolicy: FetchPolicy.CacheBeforeNetwork,
199
206
  });
200
207
 
201
208
  // OK, now which result do we return.
@@ -17,9 +17,9 @@ type CacheValueFn<TValue: ValidCacheData> = (value: ?TValue) => void;
17
17
  const cache = new ScopedInMemoryCache();
18
18
 
19
19
  /**
20
- * Clear the in-memory cache or a single scope within it.
20
+ * Purge the in-memory cache or a single scope within it.
21
21
  */
22
- export const clearSharedCache = (scope: string = "") => {
22
+ export const purgeSharedCache = (scope: string = "") => {
23
23
  // If we have a valid scope (empty string is falsy), then clear that scope.
24
24
  if (scope && typeof scope === "string") {
25
25
  cache.purgeScope(scope);
package/src/index.js CHANGED
@@ -1,14 +1,9 @@
1
1
  // @flow
2
- import {Server} from "@khanacademy/wonder-blocks-core";
3
- import {SsrCache} from "./util/ssr-cache.js";
4
- import {RequestTracker} from "./util/request-tracking.js";
5
-
6
- import type {
7
- ValidCacheData,
8
- CachedResponse,
9
- ResponseCache,
10
- } from "./util/types.js";
11
-
2
+ // TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
3
+ // have fixed:
4
+ // https://github.com/import-js/eslint-plugin-import/issues/2073
5
+ // eslint-disable-next-line import/named
6
+ export {FetchPolicy} from "./util/types.js";
12
7
  export type {
13
8
  ErrorOptions,
14
9
  ResponseCache,
@@ -18,79 +13,16 @@ export type {
18
13
  ValidCacheData,
19
14
  } from "./util/types.js";
20
15
 
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
- */
28
- export const initializeCache = (source: ResponseCache): void =>
29
- SsrCache.Default.initialize(source);
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
- */
40
- export const fulfillAllDataRequests = (): Promise<ResponseCache> => {
41
- if (!Server.isServerSide()) {
42
- return Promise.reject(
43
- new Error("Data requests are not tracked when client-side"),
44
- );
45
- }
46
- return RequestTracker.Default.fulfillTrackedRequests();
47
- };
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
- */
58
- export const hasUnfulfilledRequests = (): boolean => {
59
- if (!Server.isServerSide()) {
60
- throw new Error("Data requests are not tracked when client-side");
61
- }
62
- return RequestTracker.Default.hasUnfulfilledRequests;
63
- };
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
- */
70
- export const removeFromCache = (id: string): boolean =>
71
- SsrCache.Default.remove(id);
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
- */
80
- export const removeAllFromCache = (
81
- predicate?: (
82
- key: string,
83
- cacheEntry: ?$ReadOnly<CachedResponse<ValidCacheData>>,
84
- ) => boolean,
85
- ): void => SsrCache.Default.removeAll(predicate);
86
-
16
+ export * from "./util/hydration-cache-api.js";
17
+ export * from "./util/request-api.js";
18
+ export {purgeCaches} from "./util/purge-caches.js";
87
19
  export {default as TrackData} from "./components/track-data.js";
88
20
  export {default as Data} from "./components/data.js";
89
21
  export {default as InterceptRequests} from "./components/intercept-requests.js";
90
22
  export {DataError, DataErrors} from "./util/data-error.js";
91
23
  export {useServerEffect} from "./hooks/use-server-effect.js";
92
24
  export {useCachedEffect} from "./hooks/use-cached-effect.js";
93
- export {useSharedCache, clearSharedCache} from "./hooks/use-shared-cache.js";
25
+ export {useSharedCache, purgeSharedCache} from "./hooks/use-shared-cache.js";
94
26
  export {
95
27
  useHydratableEffect,
96
28
  // TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
@@ -101,10 +33,14 @@ export {
101
33
  } from "./hooks/use-hydratable-effect.js";
102
34
  export {ScopedInMemoryCache} from "./util/scoped-in-memory-cache.js";
103
35
  export {SerializableInMemoryCache} from "./util/serializable-in-memory-cache.js";
104
- export {RequestFulfillment} from "./util/request-fulfillment.js";
105
36
  export {Status} from "./util/status.js";
106
37
 
38
+ ////////////////////////////////////////////////////////////////////////////////
107
39
  // GraphQL
40
+ ////////////////////////////////////////////////////////////////////////////////
41
+ export {getGqlRequestId} from "./util/get-gql-request-id.js";
42
+ export {graphQLDocumentNodeParser} from "./util/graphql-document-node-parser.js";
43
+ export {toGqlOperation} from "./util/to-gql-operation.js";
108
44
  export {GqlRouter} from "./components/gql-router.js";
109
45
  export {useGql} from "./hooks/use-gql.js";
110
46
  export {GqlError, GqlErrors} from "./util/gql-error.js";
@@ -0,0 +1,74 @@
1
+ // @flow
2
+
3
+ import {getGqlRequestId} from "../get-gql-request-id.js";
4
+
5
+ describe("#getGqlRequestId", () => {
6
+ it("should include the id of the query", () => {
7
+ // Arrange
8
+ const operation = {
9
+ type: "query",
10
+ id: "myQuery",
11
+ };
12
+
13
+ // Act
14
+ const requestId = getGqlRequestId(operation, null, {
15
+ module: "MODULE",
16
+ curriculum: "CURRICULUM",
17
+ targetLocale: "LOCALE",
18
+ });
19
+ const result = new Set(requestId.split("|"));
20
+
21
+ // Assert
22
+ expect(result).toContain("myQuery");
23
+ });
24
+
25
+ it("should include the context values sorted by key", () => {
26
+ // Arrange
27
+ const operation = {
28
+ type: "query",
29
+ id: "myQuery",
30
+ };
31
+ const context = {
32
+ context3: "value3",
33
+ context2: "value2",
34
+ context1: "value1",
35
+ };
36
+
37
+ // Act
38
+ const requestId = getGqlRequestId(operation, null, context);
39
+ const result = new Set(requestId.split("|"));
40
+
41
+ // Assert
42
+ expect(result).toContain(
43
+ `context1=value1&context2=value2&context3=value3`,
44
+ );
45
+ });
46
+
47
+ it("should include the variables, sorted by key, if present", () => {
48
+ // Arrange
49
+ const operation = {
50
+ type: "query",
51
+ id: "myQuery",
52
+ };
53
+ const variables = {
54
+ variable4: null,
55
+ variable2: 42,
56
+ variable1: "value1",
57
+ variable5: true,
58
+ variable3: undefined,
59
+ };
60
+
61
+ // Act
62
+ const requestId = getGqlRequestId(operation, variables, {
63
+ module: "MODULE",
64
+ curriculum: "CURRICULUM",
65
+ targetLocale: "LOCALE",
66
+ });
67
+ const result = new Set(requestId.split("|"));
68
+
69
+ // Assert
70
+ expect(result).toContain(
71
+ `variable1=value1&variable2=42&variable3=&variable4=null&variable5=true`,
72
+ );
73
+ });
74
+ });