@khanacademy/wonder-blocks-data 8.0.4 → 9.1.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.
@@ -19,12 +19,12 @@ function useSharedCache<TValue: ValidCacheData>(
19
19
  ): [?TValue, CacheValueFn<TValue>];
20
20
  ```
21
21
 
22
- The `useSharedCache` hook provides access to a shared in-memory cache. This cache is not part of the cache hydrated by Wonder Blocks Data, so [`purgeSharedCache`](/docs/data-exports-purgesharedcache--page) must be called between server-side render cycles.
22
+ The `useSharedCache` hook provides access to a shared in-memory cache. This cache is not part of the cache hydrated by Wonder Blocks Data, so [`SharedCache.purgeAll()`](/docs/data-exports-sharedcache--page) must be called between server-side render cycles.
23
23
 
24
24
  The hook returns a tuple of the currently cached value, or `null` if none is cached, and a function that can be used to set the cached value.
25
25
 
26
26
  The shared cache is passive and as such does not notify of changes to its contents.
27
27
 
28
- Each cached item is identified by an id and a scope. The scope is used to group items. Whole scopes can be cleared by specifying the specific scope when calling [`purgeSharedCache`](/docs/data-exports-purgesharedcache--page).
28
+ Each cached item is identified by an id and a scope. The scope is used to group items. Whole scopes can be cleared by specifying the specific scope when calling [`SharedCache.purgeScope()`](/docs/data-exports-sharedcache--page).
29
29
 
30
30
  An optional argument, `initialValue` can be given. This can be either the value to be cached itself or a function that returns the value to be cached (functions themselves are not valid cachable values). This allows for expensive initialization to only occur when it is necessary.
@@ -0,0 +1,27 @@
1
+ import {Meta} from "@storybook/addon-docs";
2
+
3
+ <Meta
4
+ title="Data / Types / RawScopedCache"
5
+ parameters={{
6
+ chromatic: {
7
+ disableSnapshot: true,
8
+ },
9
+ }}
10
+ />
11
+
12
+ # RawScopedCache
13
+
14
+ ```ts
15
+ type RawScopedCache = {
16
+ [scope: string]: {
17
+ [id: string]: ValidCacheData,
18
+ ...
19
+ },
20
+ ...
21
+ };
22
+
23
+ ```
24
+
25
+ `RawScopedCache` describes a cache that has distinct scoped sections in its raw object form. This is the representation of the caches used internally by Wonder Blocks Data to support the scoping of requests when using hooks such as [`useSharedCache`](/docs/data-exports-use-shared-cache--page), [`useCachedEffect`](/docs/data-exports-use-cached-effect--page), and [`useHydratableEffect`](/docs/data-exports-use-hydratable-effect--page).
26
+
27
+ See the section on [server-side rendering](/docs/data-server-side-rendering-and-hydration--page) for more information.
@@ -12,16 +12,103 @@ import {Meta} from "@storybook/addon-docs";
12
12
  # ScopedCache
13
13
 
14
14
  ```ts
15
- type ScopedCache = {
16
- [scope: string]: {
17
- [id: string]: ValidCacheData,
18
- ...
19
- },
20
- ...
21
- };
15
+ interface ScopedCache {
16
+ set(scope: string, id: string, value: ValidCacheData): void;
22
17
 
18
+ /**
19
+ * Retrieve a value from the cache.
20
+ */
21
+ get(scope: string, id: string): ?ValidCacheData;
22
+
23
+ /**
24
+ * Purge an item from the cache.
25
+ */
26
+ purge(scope: string, id: string): void;
27
+
28
+ /**
29
+ * Purge a scope of items that match the given predicate.
30
+ *
31
+ * If the predicate is omitted, then all items in the scope are purged.
32
+ */
33
+ purgeScope(
34
+ scope: string,
35
+ predicate?: (id: string, value: ValidCacheData) => boolean,
36
+ ): void;
37
+
38
+ /**
39
+ * Purge all items from the cache that match the given predicate.
40
+ *
41
+ * If the predicate is omitted, then all items in the cache are purged.
42
+ */
43
+ purgeAll(
44
+ predicate?: (
45
+ scope: string,
46
+ id: string,
47
+ value: ValidCacheData,
48
+ ) => boolean,
49
+ ): void;
50
+ }
51
+ ```
52
+
53
+ This interface defines how to interact with a scoped cache, such as [`ScopedInMemoryCache`](/docs/data-exports-scopedinmemorycache--page).
54
+
55
+ ## set()
56
+
57
+ ```ts
58
+ set(
59
+ scope: string,
60
+ id: string,
61
+ value: TValue,
62
+ ): void;
63
+ ```
64
+
65
+ Sets a value in the cache within a given scope.
66
+
67
+ ### Throws
68
+
69
+ | Error Type | Error Name | Reason |
70
+ | ------ | ------ | ------ |
71
+ | [`DataError`](/docs/data-exports-dataerror--page) | `InvalidInputDataError` | `id` and `scope` must be non-empty strings |
72
+ | [`DataError`](/docs/data-exports-dataerror--page) | `InvalidInputDataError` | `value` must be a non-function value |
73
+
74
+ ## get()
75
+
76
+ ```ts
77
+ get(scope: string, id: string): ?ValidCacheData;
78
+ ```
79
+
80
+ Gets a value from the cache. If a value with the given identifier (`id`) is not found within the given scope (`scope`) of the cache, `null` is returned.
81
+
82
+ ## purge()
83
+
84
+ ```ts
85
+ purge(scope: string, id: string): void;
86
+ ```
87
+
88
+ Purges the value from the cache. If a value with the given identifier (`id`) is not found within the given scope (`scope`) of the cache, nothing happens.
89
+
90
+ ## purgeScope()
91
+
92
+ ```ts
93
+ purgeScope(
94
+ scope: string,
95
+ predicate?: (id: string, value: ValidCacheData) => boolean,
96
+ ): void;
97
+ ```
98
+
99
+ Purges items within a given scope (`scope`) of the cache from that scope. If a predicate is provided, only items for which the predicate returns `true` will be purged; otherwise, the entire scope will be purged.
100
+
101
+ ## purgeAll()
102
+
103
+ ```ts
104
+ purgeAll(
105
+ predicate?: (
106
+ scope: string,
107
+ id: string,
108
+ value: ValidCacheData,
109
+ ) => boolean,
110
+ ): void;
23
111
  ```
24
112
 
25
- `ScopedCache` describes a cache that has distinct scoped sections. This is the representation of the caches used internally by Wonder Blocks Data to support the scoping of requests when using hooks such as [`useSharedCache`](/docs/data-exports-use-shared-cache--page), [`useCachedEffect`](/docs/data-exports-use-cached-effect--page), and [`useHydratableEffect`](/docs/data-exports-use-hydratable-effect--page).
113
+ Purges all items from the cache. If a predicate is provided, only items for which the predicate returns `true` will be purged; otherwise, the entire cache will be purged.
26
114
 
27
- See the section on [server-side rendering](/docs/data-server-side-rendering-and-hydration--page) for more information.
@@ -7,7 +7,7 @@ import {render, act} from "@testing-library/react";
7
7
  import * as ReactDOMServer from "react-dom/server";
8
8
  import {Server, View} from "@khanacademy/wonder-blocks-core";
9
9
 
10
- import {purgeSharedCache} from "../../hooks/use-shared-cache.js";
10
+ import {SharedCache} from "../../hooks/use-shared-cache.js";
11
11
  import TrackData from "../track-data.js";
12
12
  import {RequestFulfillment} from "../../util/request-fulfillment.js";
13
13
  import {SsrCache} from "../../util/ssr-cache.js";
@@ -24,7 +24,7 @@ import {
24
24
 
25
25
  describe("Data", () => {
26
26
  beforeEach(() => {
27
- purgeSharedCache();
27
+ SharedCache.purgeAll();
28
28
 
29
29
  const responseCache = new SsrCache();
30
30
  jest.spyOn(SsrCache, "Default", "get").mockReturnValue(responseCache);
@@ -1,11 +1,11 @@
1
1
  // @flow
2
2
  import {renderHook as clientRenderHook} from "@testing-library/react-hooks";
3
3
 
4
- import {useSharedCache, purgeSharedCache} from "../use-shared-cache.js";
4
+ import {useSharedCache, SharedCache} from "../use-shared-cache.js";
5
5
 
6
6
  describe("#useSharedCache", () => {
7
7
  beforeEach(() => {
8
- purgeSharedCache();
8
+ SharedCache.purgeAll();
9
9
  });
10
10
 
11
11
  it.each`
@@ -257,51 +257,3 @@ describe("#useSharedCache", () => {
257
257
  expect(result).toBeNull();
258
258
  });
259
259
  });
260
-
261
- describe("#purgeSharedCache", () => {
262
- beforeEach(() => {
263
- purgeSharedCache();
264
- });
265
-
266
- it("should clear the entire cache if no scope given", () => {
267
- // Arrange
268
- const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1"));
269
- const hook2 = clientRenderHook(() => useSharedCache("id2", "scope2"));
270
- hook1.result.current[1]("VALUE_1");
271
- hook2.result.current[1]("VALUE_2");
272
- // Make sure both hook results include the updated value.
273
- hook1.rerender();
274
- hook2.rerender();
275
-
276
- // Act
277
- purgeSharedCache();
278
- // Make sure we refresh the hook results.
279
- hook1.rerender();
280
- hook2.rerender();
281
-
282
- // Assert
283
- expect(hook1.result.current[0]).toBeNull();
284
- expect(hook2.result.current[0]).toBeNull();
285
- });
286
-
287
- it("should clear the given scope only", () => {
288
- // Arrange
289
- const hook1 = clientRenderHook(() => useSharedCache("id1", "scope1"));
290
- const hook2 = clientRenderHook(() => useSharedCache("id2", "scope2"));
291
- hook1.result.current[1]("VALUE_1");
292
- hook2.result.current[1]("VALUE_2");
293
- // Make sure both hook results include the updated value.
294
- hook1.rerender();
295
- hook2.rerender();
296
-
297
- // Act
298
- purgeSharedCache("scope2");
299
- // Make sure we refresh the hook results.
300
- hook1.rerender();
301
- hook2.rerender();
302
-
303
- // Assert
304
- expect(hook1.result.current[0]).toBe("VALUE_1");
305
- expect(hook2.result.current[0]).toBeNull();
306
- });
307
- });
@@ -2,7 +2,7 @@
2
2
  import * as React from "react";
3
3
  import {DataError, DataErrors} from "../util/data-error.js";
4
4
  import {ScopedInMemoryCache} from "../util/scoped-in-memory-cache.js";
5
- import type {ValidCacheData} from "../util/types.js";
5
+ import type {ValidCacheData, ScopedCache} from "../util/types.js";
6
6
 
7
7
  /**
8
8
  * A function for inserting a value into the cache or clearing it.
@@ -17,17 +17,12 @@ type CacheValueFn<TValue: ValidCacheData> = (value: ?TValue) => void;
17
17
  const cache = new ScopedInMemoryCache();
18
18
 
19
19
  /**
20
- * Purge the in-memory cache or a single scope within it.
20
+ * Access to the shared in-memory cache.
21
+ *
22
+ * This is the cache used by `useSharedCache` and related hooks and
23
+ * components.
21
24
  */
22
- export const purgeSharedCache = (scope: string = "") => {
23
- // If we have a valid scope (empty string is falsy), then clear that scope.
24
- if (scope && typeof scope === "string") {
25
- cache.purgeScope(scope);
26
- } else {
27
- // Just reset the object. This should be sufficient.
28
- cache.purgeAll();
29
- }
30
- };
25
+ export const SharedCache: ScopedCache = cache;
31
26
 
32
27
  /**
33
28
  * Hook to retrieve data from and store data in an in-memory cache.
@@ -37,9 +32,6 @@ export const purgeSharedCache = (scope: string = "") => {
37
32
  * function to set the cache entry (passing null or undefined to this function
38
33
  * will delete the entry).
39
34
  *
40
- * To clear a single scope within the cache or the entire cache,
41
- * the `clearScopedCache` export is available.
42
- *
43
35
  * NOTE: Unlike useState or useReducer, we don't automatically update folks
44
36
  * if the value they reference changes. We might add it later (if we need to),
45
37
  * but the likelihood here is that things won't be changing in this cache in a
package/src/index.js CHANGED
@@ -9,8 +9,9 @@ export type {
9
9
  ResponseCache,
10
10
  CachedResponse,
11
11
  Result,
12
- ScopedCache,
12
+ RawScopedCache,
13
13
  ValidCacheData,
14
+ ScopedCache,
14
15
  } from "./util/types.js";
15
16
 
16
17
  export * from "./util/hydration-cache-api.js";
@@ -22,7 +23,7 @@ export {default as InterceptRequests} from "./components/intercept-requests.js";
22
23
  export {DataError, DataErrors} from "./util/data-error.js";
23
24
  export {useServerEffect} from "./hooks/use-server-effect.js";
24
25
  export {useCachedEffect} from "./hooks/use-cached-effect.js";
25
- export {useSharedCache, purgeSharedCache} from "./hooks/use-shared-cache.js";
26
+ export {useSharedCache, SharedCache} from "./hooks/use-shared-cache.js";
26
27
  export {
27
28
  useHydratableEffect,
28
29
  // TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
@@ -39,6 +40,7 @@ export {Status} from "./util/status.js";
39
40
  // GraphQL
40
41
  ////////////////////////////////////////////////////////////////////////////////
41
42
  export {getGqlRequestId} from "./util/get-gql-request-id.js";
43
+ export {getGqlDataFromResponse} from "./util/get-gql-data-from-response.js";
42
44
  export {graphQLDocumentNodeParser} from "./util/graphql-document-node-parser.js";
43
45
  export {toGqlOperation} from "./util/to-gql-operation.js";
44
46
  export {GqlRouter} from "./components/gql-router.js";
@@ -71,4 +71,37 @@ describe("#getGqlRequestId", () => {
71
71
  `variable1=value1&variable2=42&variable3=&variable4=null&variable5=true`,
72
72
  );
73
73
  });
74
+
75
+ it("should sort nested variable properties", () => {
76
+ // Arrange
77
+ const operation = {
78
+ type: "query",
79
+ id: "myQuery",
80
+ };
81
+ const variables = {
82
+ variable4: null,
83
+ variable2: 42,
84
+ variable1: "value1",
85
+ variable5: true,
86
+ variable3: undefined,
87
+ variable6: {
88
+ nested2: "nested2",
89
+ nested1: "nested1",
90
+ },
91
+ variable7: [1, 2, 3],
92
+ };
93
+
94
+ // Act
95
+ const requestId = getGqlRequestId(operation, variables, {
96
+ module: "MODULE",
97
+ curriculum: "CURRICULUM",
98
+ targetLocale: "LOCALE",
99
+ });
100
+ const result = new Set(requestId.split("|"));
101
+
102
+ // Assert
103
+ expect(result).toContain(
104
+ `variable1=value1&variable2=42&variable3=&variable4=null&variable5=true&variable6.nested1=nested1&variable6.nested2=nested2&variable7.0=1&variable7.1=2&variable7.2=3`,
105
+ );
106
+ });
74
107
  });
@@ -1,5 +1,5 @@
1
1
  // @flow
2
- import * as UseSharedCache from "../../hooks/use-shared-cache.js";
2
+ import {SharedCache} from "../../hooks/use-shared-cache.js";
3
3
  import * as HydrationCacheApi from "../hydration-cache-api.js";
4
4
 
5
5
  import {purgeCaches} from "../purge-caches.js";
@@ -7,7 +7,7 @@ import {purgeCaches} from "../purge-caches.js";
7
7
  describe("#purgeCaches", () => {
8
8
  it("should purge the shared cache", () => {
9
9
  // Arrange
10
- const spy = jest.spyOn(UseSharedCache, "purgeSharedCache");
10
+ const spy = jest.spyOn(SharedCache, "purgeAll");
11
11
 
12
12
  // Act
13
13
  purgeCaches();
@@ -1,11 +1,28 @@
1
1
  // @flow
2
2
  import type {GqlOperation, GqlContext} from "./gql-types.js";
3
3
 
4
- const toString = (valid: mixed): string => {
5
- if (typeof valid === "string") {
6
- return valid;
4
+ const toString = (value: mixed): string => {
5
+ if (typeof value === "string") {
6
+ return value;
7
7
  }
8
- return JSON.stringify(valid) ?? "";
8
+ return JSON.stringify(value) ?? "";
9
+ };
10
+
11
+ const toStringifiedVariables = (acc: any, key: string, value: mixed): any => {
12
+ if (typeof value === "object" && value !== null) {
13
+ // If we have an object or array, we build sub-variables so that
14
+ // the ID is easily human-readable rather than having lots of
15
+ // extra %-encodings. This means that an object or array variable
16
+ // turns into x variables, where x is the field or element count of
17
+ // variable. See below for example.
18
+ return Object.entries(value).reduce((innerAcc, [i, v]) => {
19
+ const subKey = `${key}.${i}`;
20
+ return toStringifiedVariables(innerAcc, subKey, v);
21
+ }, acc);
22
+ } else {
23
+ acc[key] = toString(value);
24
+ }
25
+ return acc;
9
26
  };
10
27
 
11
28
  /**
@@ -32,10 +49,37 @@ export const getGqlRequestId = <TData, TVariables: {...}>(
32
49
  // Finally, if we have variables, we add those too.
33
50
  if (variables != null) {
34
51
  // We need to turn each variable into a string.
52
+ // We also need to ensure we sort any sub-object keys.
53
+ // `toStringifiedVariables` helps us with this by hoisting nested
54
+ // data to individual variables for the purposes of ID generation.
55
+ //
56
+ // For example, consider variables:
57
+ // {x: [1,2,3], y: {a: 1, b: 2, c: 3}, z: 123}
58
+ //
59
+ // Each variable, x, y and z, would be stringified into
60
+ // stringifiedVariables as follows:
61
+ // x becomes {"x.0": "1", "x.1": "2", "x.2": "3"}
62
+ // y becomes {"y.a": "1", "y.b": "2", "y.c": "3"}
63
+ // z becomes {"z": "123"}
64
+ //
65
+ // This then leads to stringifiedVariables being:
66
+ // {
67
+ // "x.0": "1",
68
+ // "x.1": "2",
69
+ // "x.2": "3",
70
+ // "y.a": "1",
71
+ // "y.b": "2",
72
+ // "y.c": "3",
73
+ // "z": "123",
74
+ // }
75
+ //
76
+ // Thus allowing our use of URLSearchParams to both sort and easily
77
+ // encode the variables into an idempotent identifier for those
78
+ // variable values that is also human-readable.
35
79
  const stringifiedVariables = Object.keys(variables).reduce(
36
80
  (acc, key) => {
37
- acc[key] = toString(variables[key]);
38
- return acc;
81
+ const value = variables[key];
82
+ return toStringifiedVariables(acc, key, value);
39
83
  },
40
84
  {},
41
85
  );
@@ -1,5 +1,5 @@
1
1
  // @flow
2
- import {purgeSharedCache} from "../hooks/use-shared-cache.js";
2
+ import {SharedCache} from "../hooks/use-shared-cache.js";
3
3
  import {purgeHydrationCache} from "./hydration-cache-api.js";
4
4
 
5
5
  /**
@@ -10,6 +10,6 @@ import {purgeHydrationCache} from "./hydration-cache-api.js";
10
10
  * which caches may have been used during a given test run.
11
11
  */
12
12
  export const purgeCaches = () => {
13
- purgeSharedCache();
13
+ SharedCache.purgeAll();
14
14
  purgeHydrationCache();
15
15
  };
@@ -1,14 +1,14 @@
1
1
  // @flow
2
2
  import {DataError, DataErrors} from "./data-error.js";
3
- import type {ScopedCache, ValidCacheData} from "./types.js";
3
+ import type {ScopedCache, RawScopedCache, ValidCacheData} from "./types.js";
4
4
 
5
5
  /**
6
6
  * Describe an in-memory cache.
7
7
  */
8
- export class ScopedInMemoryCache {
9
- _cache: ScopedCache;
8
+ export class ScopedInMemoryCache implements ScopedCache {
9
+ _cache: RawScopedCache;
10
10
 
11
- constructor(initialCache: ScopedCache = {}) {
11
+ constructor(initialCache: RawScopedCache = {}) {
12
12
  this._cache = initialCache;
13
13
  }
14
14
 
@@ -24,11 +24,7 @@ export class ScopedInMemoryCache {
24
24
  /**
25
25
  * Set a value in the cache.
26
26
  */
27
- set<TValue: ValidCacheData>(
28
- scope: string,
29
- id: string,
30
- value: TValue,
31
- ): void {
27
+ set(scope: string, id: string, value: ValidCacheData): void {
32
28
  if (!id || typeof id !== "string") {
33
29
  throw new DataError(
34
30
  "id must be non-empty string",
@@ -2,13 +2,13 @@
2
2
  import {clone} from "@khanacademy/wonder-stuff-core";
3
3
  import {DataError, DataErrors} from "./data-error.js";
4
4
  import {ScopedInMemoryCache} from "./scoped-in-memory-cache.js";
5
- import type {ValidCacheData, ScopedCache} from "./types.js";
5
+ import type {ValidCacheData, RawScopedCache} from "./types.js";
6
6
 
7
7
  /**
8
8
  * Describe a serializable in-memory cache.
9
9
  */
10
10
  export class SerializableInMemoryCache extends ScopedInMemoryCache {
11
- constructor(initialCache: ScopedCache = {}) {
11
+ constructor(initialCache: RawScopedCache = {}) {
12
12
  try {
13
13
  super(clone(initialCache));
14
14
  } catch (e) {
@@ -22,18 +22,14 @@ export class SerializableInMemoryCache extends ScopedInMemoryCache {
22
22
  /**
23
23
  * Set a value in the cache.
24
24
  */
25
- set<TValue: ValidCacheData>(
26
- scope: string,
27
- id: string,
28
- value: TValue,
29
- ): void {
25
+ set(scope: string, id: string, value: ValidCacheData): void {
30
26
  super.set(scope, id, Object.freeze(clone(value)));
31
27
  }
32
28
 
33
29
  /**
34
30
  * Clone the cache.
35
31
  */
36
- clone(): ScopedCache {
32
+ clone(): RawScopedCache {
37
33
  try {
38
34
  return clone(this._cache);
39
35
  } catch (e) {
package/src/util/types.js CHANGED
@@ -84,7 +84,7 @@ export type ResponseCache = {
84
84
  /**
85
85
  * A cache with scoped sections.
86
86
  */
87
- export type ScopedCache = {
87
+ export type RawScopedCache = {
88
88
  /**
89
89
  * The cache is scoped to allow easier clearing of different types of usage.
90
90
  */
@@ -112,3 +112,40 @@ export type ErrorOptions = {|
112
112
  */
113
113
  cause?: ?Error,
114
114
  |};
115
+
116
+ export interface ScopedCache {
117
+ set(scope: string, id: string, value: ValidCacheData): void;
118
+
119
+ /**
120
+ * Retrieve a value from the cache.
121
+ */
122
+ get(scope: string, id: string): ?ValidCacheData;
123
+
124
+ /**
125
+ * Purge an item from the cache.
126
+ */
127
+ purge(scope: string, id: string): void;
128
+
129
+ /**
130
+ * Purge a scope of items that match the given predicate.
131
+ *
132
+ * If the predicate is omitted, then all items in the scope are purged.
133
+ */
134
+ purgeScope(
135
+ scope: string,
136
+ predicate?: (id: string, value: ValidCacheData) => boolean,
137
+ ): void;
138
+
139
+ /**
140
+ * Purge all items from the cache that match the given predicate.
141
+ *
142
+ * If the predicate is omitted, then all items in the cache are purged.
143
+ */
144
+ purgeAll(
145
+ predicate?: (
146
+ scope: string,
147
+ id: string,
148
+ value: ValidCacheData,
149
+ ) => boolean,
150
+ ): void;
151
+ }
@@ -1,20 +0,0 @@
1
- import {Meta} from "@storybook/addon-docs";
2
-
3
- <Meta
4
- title="Data / Exports / purgeSharedCache()"
5
- parameters={{
6
- chromatic: {
7
- disableSnapshot: true,
8
- },
9
- }}
10
- />
11
-
12
- # purgeSharedCache()
13
-
14
- ```ts
15
- purgeSharedCache(scope?: string): void;
16
- ```
17
-
18
- The `purgeSharedCache` method can be used to clear the shared in-memory cache used by the [`useSharedCache`](/docs/data-exports-usesharedcache--page) hook. Either a single scope or all scopes can be cleared.
19
-
20
- Common uses for calling this method are during [server-side rendering](/docs/data-server-side-rendering-and-hydration--page) to ensure each render cycle remains isolated, or during testing in a `beforeEach` to cover for where previous test cases may have changed the shared cache.