@khanacademy/wonder-blocks-data 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/es/index.js +793 -375
- package/dist/index.js +1203 -523
- package/legacy-docs.md +3 -0
- package/package.json +2 -2
- package/src/__docs__/_overview_.stories.mdx +18 -0
- package/src/__docs__/_overview_graphql.stories.mdx +35 -0
- package/src/__docs__/_overview_ssr_.stories.mdx +185 -0
- package/src/__docs__/_overview_testing_.stories.mdx +123 -0
- package/src/__docs__/exports.clear-shared-cache.stories.mdx +20 -0
- package/src/__docs__/exports.data-error.stories.mdx +23 -0
- package/src/__docs__/exports.data-errors.stories.mdx +23 -0
- package/src/{components/data.md → __docs__/exports.data.stories.mdx} +15 -18
- package/src/__docs__/exports.fulfill-all-data-requests.stories.mdx +24 -0
- package/src/__docs__/exports.gql-error.stories.mdx +23 -0
- package/src/__docs__/exports.gql-errors.stories.mdx +20 -0
- package/src/__docs__/exports.gql-router.stories.mdx +29 -0
- package/src/__docs__/exports.has-unfulfilled-requests.stories.mdx +20 -0
- package/src/__docs__/exports.intercept-requests.stories.mdx +69 -0
- package/src/__docs__/exports.intialize-cache.stories.mdx +29 -0
- package/src/__docs__/exports.remove-all-from-cache.stories.mdx +24 -0
- package/src/__docs__/exports.remove-from-cache.stories.mdx +25 -0
- package/src/__docs__/exports.request-fulfillment.stories.mdx +36 -0
- package/src/__docs__/exports.scoped-in-memory-cache.stories.mdx +92 -0
- package/src/__docs__/exports.serializable-in-memory-cache.stories.mdx +112 -0
- package/src/__docs__/exports.status.stories.mdx +31 -0
- package/src/{components/track-data.md → __docs__/exports.track-data.stories.mdx} +15 -0
- package/src/__docs__/exports.use-cached-effect.stories.mdx +41 -0
- package/src/__docs__/exports.use-gql.stories.mdx +73 -0
- package/src/__docs__/exports.use-hydratable-effect.stories.mdx +43 -0
- package/src/__docs__/exports.use-server-effect.stories.mdx +38 -0
- package/src/__docs__/exports.use-shared-cache.stories.mdx +30 -0
- package/src/__docs__/exports.when-client-side.stories.mdx +33 -0
- package/src/__docs__/types.cached-response.stories.mdx +29 -0
- package/src/__docs__/types.error-options.stories.mdx +21 -0
- package/src/__docs__/types.gql-context.stories.mdx +20 -0
- package/src/__docs__/types.gql-fetch-fn.stories.mdx +24 -0
- package/src/__docs__/types.gql-fetch-options.stories.mdx +24 -0
- package/src/__docs__/types.gql-operation-type.stories.mdx +24 -0
- package/src/__docs__/types.gql-operation.stories.mdx +67 -0
- package/src/__docs__/types.response-cache.stories.mdx +33 -0
- package/src/__docs__/types.result.stories.mdx +39 -0
- package/src/__docs__/types.scoped-cache.stories.mdx +27 -0
- package/src/__docs__/types.valid-cache-data.stories.mdx +23 -0
- package/src/__tests__/__snapshots__/generated-snapshot.test.js.snap +0 -80
- package/src/__tests__/generated-snapshot.test.js +7 -31
- package/src/components/__tests__/data.test.js +160 -154
- package/src/components/__tests__/intercept-requests.test.js +58 -0
- package/src/components/data.js +22 -126
- package/src/components/intercept-context.js +4 -5
- package/src/components/intercept-requests.js +69 -0
- package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
- package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
- package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
- package/src/hooks/__tests__/use-gql.test.js +1 -30
- package/src/hooks/__tests__/use-hydratable-effect.test.js +708 -0
- package/src/hooks/__tests__/use-request-interception.test.js +255 -0
- package/src/hooks/__tests__/use-server-effect.test.js +39 -11
- package/src/hooks/use-cached-effect.js +225 -0
- package/src/hooks/use-gql-router-context.js +50 -0
- package/src/hooks/use-gql.js +22 -52
- package/src/hooks/use-hydratable-effect.js +206 -0
- package/src/hooks/use-request-interception.js +51 -0
- package/src/hooks/use-server-effect.js +14 -7
- package/src/hooks/use-shared-cache.js +13 -11
- package/src/index.js +54 -2
- package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
- package/src/util/__tests__/merge-gql-context.test.js +74 -0
- package/src/util/__tests__/request-fulfillment.test.js +23 -42
- package/src/util/__tests__/request-tracking.test.js +26 -7
- package/src/util/__tests__/result-from-cache-response.test.js +19 -5
- package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
- package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
- package/src/util/__tests__/ssr-cache.test.js +52 -52
- package/src/util/abort-error.js +15 -0
- package/src/util/data-error.js +58 -0
- package/src/util/get-gql-data-from-response.js +3 -2
- package/src/util/gql-error.js +19 -11
- package/src/util/merge-gql-context.js +34 -0
- package/src/util/request-fulfillment.js +49 -46
- package/src/util/request-tracking.js +69 -15
- package/src/util/result-from-cache-response.js +12 -16
- package/src/util/scoped-in-memory-cache.js +24 -47
- package/src/util/serializable-in-memory-cache.js +49 -0
- package/src/util/ssr-cache.js +9 -8
- package/src/util/status.js +30 -0
- package/src/util/types.js +18 -1
- package/docs.md +0 -122
- package/src/components/__tests__/intercept-data.test.js +0 -63
- package/src/components/intercept-data.js +0 -66
- package/src/components/intercept-data.md +0 -51
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {renderHook} from "@testing-library/react-hooks";
|
|
4
|
+
import InterceptRequests from "../../components/intercept-requests.js";
|
|
5
|
+
import {useRequestInterception} from "../use-request-interception.js";
|
|
6
|
+
|
|
7
|
+
describe("#useRequestInterception", () => {
|
|
8
|
+
it("should return a function", () => {
|
|
9
|
+
// Arrange
|
|
10
|
+
|
|
11
|
+
// Act
|
|
12
|
+
const {
|
|
13
|
+
result: {current: result},
|
|
14
|
+
} = renderHook(() => useRequestInterception("ID", jest.fn()));
|
|
15
|
+
|
|
16
|
+
// Assert
|
|
17
|
+
expect(result).toBeInstanceOf(Function);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should return the same function if the arguments and context don't change", () => {
|
|
21
|
+
// Arrange
|
|
22
|
+
const handler = jest.fn();
|
|
23
|
+
|
|
24
|
+
// Act
|
|
25
|
+
const wrapper = renderHook(() => useRequestInterception("ID", handler));
|
|
26
|
+
const result1 = wrapper.result.current;
|
|
27
|
+
wrapper.rerender();
|
|
28
|
+
const result2 = wrapper.result.current;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(result1).toBe(result2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should return a new function if the requestId changes", () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const handler = jest.fn();
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const wrapper = renderHook(
|
|
40
|
+
({requestId}) => useRequestInterception(requestId, handler),
|
|
41
|
+
{initialProps: {requestId: "ID"}},
|
|
42
|
+
);
|
|
43
|
+
const result1 = wrapper.result.current;
|
|
44
|
+
wrapper.rerender({requestId: "ID2"});
|
|
45
|
+
const result2 = wrapper.result.current;
|
|
46
|
+
|
|
47
|
+
// Assert
|
|
48
|
+
expect(result1).not.toBe(result2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should return a new function if the handler changes", () => {
|
|
52
|
+
// Arrange
|
|
53
|
+
|
|
54
|
+
// Act
|
|
55
|
+
const wrapper = renderHook(
|
|
56
|
+
({handler}) => useRequestInterception("ID", handler),
|
|
57
|
+
{initialProps: {handler: jest.fn()}},
|
|
58
|
+
);
|
|
59
|
+
const result1 = wrapper.result.current;
|
|
60
|
+
wrapper.rerender({handler: jest.fn()});
|
|
61
|
+
const result2 = wrapper.result.current;
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
64
|
+
expect(result1).not.toBe(result2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should return a new function if the context changes", () => {
|
|
68
|
+
// Arrange
|
|
69
|
+
const handler = jest.fn();
|
|
70
|
+
const interceptor1 = jest.fn();
|
|
71
|
+
const interceptor2 = jest.fn();
|
|
72
|
+
const Wrapper = ({children, interceptor}: any) => (
|
|
73
|
+
<InterceptRequests interceptor={interceptor}>
|
|
74
|
+
{children}
|
|
75
|
+
</InterceptRequests>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
const wrapper = renderHook(
|
|
80
|
+
() => useRequestInterception("ID", handler),
|
|
81
|
+
{wrapper: Wrapper, initialProps: {interceptor: interceptor1}},
|
|
82
|
+
);
|
|
83
|
+
const result1 = wrapper.result.current;
|
|
84
|
+
wrapper.rerender({wrapper: Wrapper, interceptor: interceptor2});
|
|
85
|
+
const result2 = wrapper.result.current;
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
88
|
+
expect(result1).not.toBe(result2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("returned function", () => {
|
|
92
|
+
it("should invoke the original handler when there are no interceptors", () => {
|
|
93
|
+
// Arrange
|
|
94
|
+
const handler = jest.fn();
|
|
95
|
+
const requestId = "ID";
|
|
96
|
+
const {
|
|
97
|
+
result: {current: interceptedHandler},
|
|
98
|
+
} = renderHook(() => useRequestInterception(requestId, handler));
|
|
99
|
+
|
|
100
|
+
// Act
|
|
101
|
+
interceptedHandler();
|
|
102
|
+
|
|
103
|
+
// Assert
|
|
104
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should invoke interceptors nearest to furthest", () => {
|
|
108
|
+
// Arrange
|
|
109
|
+
const handler = jest.fn();
|
|
110
|
+
const interceptorFurthest = jest.fn(() => null);
|
|
111
|
+
const interceptorNearest = jest.fn(() => null);
|
|
112
|
+
const Wrapper = ({children}: any) => (
|
|
113
|
+
<InterceptRequests interceptor={interceptorFurthest}>
|
|
114
|
+
<InterceptRequests interceptor={interceptorNearest}>
|
|
115
|
+
{children}
|
|
116
|
+
</InterceptRequests>
|
|
117
|
+
</InterceptRequests>
|
|
118
|
+
);
|
|
119
|
+
const {
|
|
120
|
+
result: {current: interceptedHandler},
|
|
121
|
+
} = renderHook(() => useRequestInterception("ID", handler), {
|
|
122
|
+
wrapper: Wrapper,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
interceptedHandler();
|
|
127
|
+
|
|
128
|
+
// Assert
|
|
129
|
+
expect(interceptorNearest).toHaveBeenCalledBefore(
|
|
130
|
+
interceptorFurthest,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should invoke the handler last", () => {
|
|
135
|
+
// Arrange
|
|
136
|
+
const handler = jest.fn();
|
|
137
|
+
const interceptorFurthest = jest.fn(() => null);
|
|
138
|
+
const interceptorNearest = jest.fn(() => null);
|
|
139
|
+
const Wrapper = ({children}: any) => (
|
|
140
|
+
<InterceptRequests interceptor={interceptorFurthest}>
|
|
141
|
+
<InterceptRequests interceptor={interceptorNearest}>
|
|
142
|
+
{children}
|
|
143
|
+
</InterceptRequests>
|
|
144
|
+
</InterceptRequests>
|
|
145
|
+
);
|
|
146
|
+
const {
|
|
147
|
+
result: {current: interceptedHandler},
|
|
148
|
+
} = renderHook(() => useRequestInterception("ID", handler), {
|
|
149
|
+
wrapper: Wrapper,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Act
|
|
153
|
+
interceptedHandler();
|
|
154
|
+
|
|
155
|
+
// Assert
|
|
156
|
+
expect(interceptorFurthest).toHaveBeenCalledBefore(handler);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should invoke the original handler when there all interceptors return null", () => {
|
|
160
|
+
// Arrange
|
|
161
|
+
const handler = jest.fn();
|
|
162
|
+
const interceptor1 = jest.fn(() => null);
|
|
163
|
+
const interceptor2 = jest.fn(() => null);
|
|
164
|
+
const Wrapper = ({children}: any) => (
|
|
165
|
+
<InterceptRequests interceptor={interceptor1}>
|
|
166
|
+
<InterceptRequests interceptor={interceptor2}>
|
|
167
|
+
{children}
|
|
168
|
+
</InterceptRequests>
|
|
169
|
+
</InterceptRequests>
|
|
170
|
+
);
|
|
171
|
+
const {
|
|
172
|
+
result: {current: interceptedHandler},
|
|
173
|
+
} = renderHook(() => useRequestInterception("ID", handler), {
|
|
174
|
+
wrapper: Wrapper,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
interceptedHandler();
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("should return the result of the nearest interceptor that returns a non-null result", async () => {
|
|
185
|
+
// Arrange
|
|
186
|
+
const handler = jest
|
|
187
|
+
.fn()
|
|
188
|
+
.mockRejectedValue(
|
|
189
|
+
new Error("This handler should have been intercepted"),
|
|
190
|
+
);
|
|
191
|
+
const interceptorFurthest = jest
|
|
192
|
+
.fn()
|
|
193
|
+
.mockRejectedValue(
|
|
194
|
+
new Error("This interceptor should not get called"),
|
|
195
|
+
);
|
|
196
|
+
const interceptorNearest = jest
|
|
197
|
+
.fn()
|
|
198
|
+
.mockResolvedValue("INTERCEPTED_DATA");
|
|
199
|
+
const Wrapper = ({children}: any) => (
|
|
200
|
+
<InterceptRequests interceptor={interceptorFurthest}>
|
|
201
|
+
<InterceptRequests interceptor={interceptorNearest}>
|
|
202
|
+
{children}
|
|
203
|
+
</InterceptRequests>
|
|
204
|
+
</InterceptRequests>
|
|
205
|
+
);
|
|
206
|
+
const {
|
|
207
|
+
result: {current: interceptedHandler},
|
|
208
|
+
} = renderHook(() => useRequestInterception("ID", handler), {
|
|
209
|
+
wrapper: Wrapper,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Act
|
|
213
|
+
const result = await interceptedHandler();
|
|
214
|
+
|
|
215
|
+
// Assert
|
|
216
|
+
expect(result).toBe("INTERCEPTED_DATA");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should not invoke interceptors or handlers beyond a non-null interception", () => {
|
|
220
|
+
// Arrange
|
|
221
|
+
const handler = jest
|
|
222
|
+
.fn()
|
|
223
|
+
.mockRejectedValue(
|
|
224
|
+
new Error("This handler should have been intercepted"),
|
|
225
|
+
);
|
|
226
|
+
const interceptorFurthest = jest
|
|
227
|
+
.fn()
|
|
228
|
+
.mockRejectedValue(
|
|
229
|
+
new Error("This interceptor should not get called"),
|
|
230
|
+
);
|
|
231
|
+
const interceptorNearest = jest
|
|
232
|
+
.fn()
|
|
233
|
+
.mockResolvedValue("INTERCEPTED_DATA");
|
|
234
|
+
const Wrapper = ({children}: any) => (
|
|
235
|
+
<InterceptRequests interceptor={interceptorFurthest}>
|
|
236
|
+
<InterceptRequests interceptor={interceptorNearest}>
|
|
237
|
+
{children}
|
|
238
|
+
</InterceptRequests>
|
|
239
|
+
</InterceptRequests>
|
|
240
|
+
);
|
|
241
|
+
const {
|
|
242
|
+
result: {current: interceptedHandler},
|
|
243
|
+
} = renderHook(() => useRequestInterception("ID", handler), {
|
|
244
|
+
wrapper: Wrapper,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Act
|
|
248
|
+
interceptedHandler();
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(handler).not.toHaveBeenCalled();
|
|
252
|
+
expect(interceptorFurthest).not.toHaveBeenCalled();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -8,23 +8,46 @@ import TrackData from "../../components/track-data.js";
|
|
|
8
8
|
import {RequestFulfillment} from "../../util/request-fulfillment.js";
|
|
9
9
|
import {SsrCache} from "../../util/ssr-cache.js";
|
|
10
10
|
import {RequestTracker} from "../../util/request-tracking.js";
|
|
11
|
+
import {DataError} from "../../util/data-error.js";
|
|
12
|
+
import * as UseRequestInterception from "../use-request-interception.js";
|
|
11
13
|
|
|
12
14
|
import {useServerEffect} from "../use-server-effect.js";
|
|
13
15
|
|
|
16
|
+
jest.mock("../use-request-interception.js");
|
|
17
|
+
|
|
14
18
|
describe("#useServerEffect", () => {
|
|
15
19
|
beforeEach(() => {
|
|
20
|
+
jest.resetAllMocks();
|
|
21
|
+
|
|
16
22
|
const responseCache = new SsrCache();
|
|
17
23
|
jest.spyOn(SsrCache, "Default", "get").mockReturnValue(responseCache);
|
|
18
24
|
jest.spyOn(RequestFulfillment, "Default", "get").mockReturnValue(
|
|
19
|
-
new RequestFulfillment(
|
|
25
|
+
new RequestFulfillment(),
|
|
20
26
|
);
|
|
21
27
|
jest.spyOn(RequestTracker, "Default", "get").mockReturnValue(
|
|
22
28
|
new RequestTracker(responseCache),
|
|
23
29
|
);
|
|
30
|
+
|
|
31
|
+
// Simple implementation of request interception that just returns
|
|
32
|
+
// the handler.
|
|
33
|
+
jest.spyOn(
|
|
34
|
+
UseRequestInterception,
|
|
35
|
+
"useRequestInterception",
|
|
36
|
+
).mockImplementation((_, handler) => handler);
|
|
24
37
|
});
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
it("should call useRequestInterception", () => {
|
|
40
|
+
// Arrange
|
|
41
|
+
const useRequestInterceptSpy = jest
|
|
42
|
+
.spyOn(UseRequestInterception, "useRequestInterception")
|
|
43
|
+
.mockReturnValue(jest.fn());
|
|
44
|
+
const fakeHandler = jest.fn();
|
|
45
|
+
|
|
46
|
+
// Act
|
|
47
|
+
serverRenderHook(() => useServerEffect("ID", fakeHandler));
|
|
48
|
+
|
|
49
|
+
// Assert
|
|
50
|
+
expect(useRequestInterceptSpy).toHaveBeenCalledWith("ID", fakeHandler);
|
|
28
51
|
});
|
|
29
52
|
|
|
30
53
|
describe("when server-side", () => {
|
|
@@ -60,9 +83,14 @@ describe("#useServerEffect", () => {
|
|
|
60
83
|
expect(fulfillRequestSpy).not.toHaveBeenCalled();
|
|
61
84
|
});
|
|
62
85
|
|
|
63
|
-
it("should track the request", () => {
|
|
86
|
+
it("should track the intercepted request", () => {
|
|
64
87
|
// Arrange
|
|
65
88
|
const fakeHandler = jest.fn();
|
|
89
|
+
const interceptedHandler = jest.fn();
|
|
90
|
+
jest.spyOn(
|
|
91
|
+
UseRequestInterception,
|
|
92
|
+
"useRequestInterception",
|
|
93
|
+
).mockReturnValue(interceptedHandler);
|
|
66
94
|
const trackDataRequestSpy = jest.spyOn(
|
|
67
95
|
RequestTracker.Default,
|
|
68
96
|
"trackDataRequest",
|
|
@@ -76,7 +104,7 @@ describe("#useServerEffect", () => {
|
|
|
76
104
|
// Assert
|
|
77
105
|
expect(trackDataRequestSpy).toHaveBeenCalledWith(
|
|
78
106
|
"ID",
|
|
79
|
-
|
|
107
|
+
interceptedHandler,
|
|
80
108
|
true,
|
|
81
109
|
);
|
|
82
110
|
});
|
|
@@ -95,7 +123,7 @@ describe("#useServerEffect", () => {
|
|
|
95
123
|
} = serverRenderHook(() => useServerEffect("ID", fakeHandler));
|
|
96
124
|
|
|
97
125
|
// Assert
|
|
98
|
-
expect(result).toEqual({
|
|
126
|
+
expect(result).toEqual({status: "success", data: "DATA"});
|
|
99
127
|
});
|
|
100
128
|
|
|
101
129
|
it("should return error cached result", () => {
|
|
@@ -113,8 +141,8 @@ describe("#useServerEffect", () => {
|
|
|
113
141
|
|
|
114
142
|
// Assert
|
|
115
143
|
expect(result).toEqual({
|
|
116
|
-
|
|
117
|
-
error:
|
|
144
|
+
status: "error",
|
|
145
|
+
error: expect.any(DataError),
|
|
118
146
|
});
|
|
119
147
|
});
|
|
120
148
|
});
|
|
@@ -151,7 +179,7 @@ describe("#useServerEffect", () => {
|
|
|
151
179
|
} = clientRenderHook(() => useServerEffect("ID", fakeHandler));
|
|
152
180
|
|
|
153
181
|
// Assert
|
|
154
|
-
expect(result).toEqual({
|
|
182
|
+
expect(result).toEqual({status: "success", data: "DATA"});
|
|
155
183
|
});
|
|
156
184
|
|
|
157
185
|
it("should return error cached result", () => {
|
|
@@ -169,8 +197,8 @@ describe("#useServerEffect", () => {
|
|
|
169
197
|
|
|
170
198
|
// Assert
|
|
171
199
|
expect(result).toEqual({
|
|
172
|
-
|
|
173
|
-
error:
|
|
200
|
+
status: "error",
|
|
201
|
+
error: expect.any(DataError),
|
|
174
202
|
});
|
|
175
203
|
});
|
|
176
204
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import {useForceUpdate} from "@khanacademy/wonder-blocks-core";
|
|
4
|
+
|
|
5
|
+
import {RequestFulfillment} from "../util/request-fulfillment.js";
|
|
6
|
+
import {Status} from "../util/status.js";
|
|
7
|
+
|
|
8
|
+
import {useSharedCache} from "./use-shared-cache.js";
|
|
9
|
+
import {useRequestInterception} from "./use-request-interception.js";
|
|
10
|
+
|
|
11
|
+
import type {Result, ValidCacheData} from "../util/types.js";
|
|
12
|
+
|
|
13
|
+
type CachedEffectOptions<TData: ValidCacheData> = {|
|
|
14
|
+
/**
|
|
15
|
+
* When `true`, the effect will not be executed; otherwise, the effect will
|
|
16
|
+
* be executed.
|
|
17
|
+
*
|
|
18
|
+
* If this is set to `true` while the effect is still pending, the pending
|
|
19
|
+
* effect will be cancelled.
|
|
20
|
+
*
|
|
21
|
+
* Default is `false`.
|
|
22
|
+
*/
|
|
23
|
+
skip?: boolean,
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* When `true`, the effect will not reset the result to the loading status
|
|
27
|
+
* while executing if the requestId changes, instead, returning
|
|
28
|
+
* the existing result from before the change; otherwise, the result will
|
|
29
|
+
* be set to loading status.
|
|
30
|
+
*
|
|
31
|
+
* If the status is loading when the changes are made, it will remain as
|
|
32
|
+
* loading; old pending effects are discarded on changes and as such this
|
|
33
|
+
* value has no effect in that case.
|
|
34
|
+
*/
|
|
35
|
+
retainResultOnChange?: boolean,
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Callback that is invoked if the result for the given hook has changed.
|
|
39
|
+
*
|
|
40
|
+
* When defined, the hook will invoke this callback whenever it has reason
|
|
41
|
+
* to change the result and will not otherwise affect component rendering
|
|
42
|
+
* directly.
|
|
43
|
+
*
|
|
44
|
+
* When not defined, the hook will ensure the component re-renders to pick
|
|
45
|
+
* up the latest result.
|
|
46
|
+
*/
|
|
47
|
+
onResultChanged?: (result: Result<TData>) => void,
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Scope to use with the shared cache.
|
|
51
|
+
*
|
|
52
|
+
* When specified, the given scope will be used to isolate this hook's
|
|
53
|
+
* cached results. Otherwise, a shared default scope will be used.
|
|
54
|
+
*
|
|
55
|
+
* Changing this value after the first call is not supported.
|
|
56
|
+
*/
|
|
57
|
+
scope?: string,
|
|
58
|
+
|};
|
|
59
|
+
|
|
60
|
+
const DefaultScope = "useCachedEffect";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hook to execute and cache an async operation on the client.
|
|
64
|
+
*
|
|
65
|
+
* This hook executes the given handler on the client if there is no
|
|
66
|
+
* cached result to use.
|
|
67
|
+
*
|
|
68
|
+
* Results are cached so they can be shared between equivalent invocations.
|
|
69
|
+
* In-flight requests are also shared, so that concurrent calls will
|
|
70
|
+
* behave as one might exect. Cache updates invoked by one hook instance
|
|
71
|
+
* do not trigger renders in components that use the same requestID; however,
|
|
72
|
+
* that should not matter since concurrent requests will share the same
|
|
73
|
+
* in-flight request, and subsequent renders will grab from the cache.
|
|
74
|
+
*
|
|
75
|
+
* Once the request has been tried once and a non-loading response has been
|
|
76
|
+
* cached, the request will not executed made again.
|
|
77
|
+
*/
|
|
78
|
+
export const useCachedEffect = <TData: ValidCacheData>(
|
|
79
|
+
requestId: string,
|
|
80
|
+
handler: () => Promise<TData>,
|
|
81
|
+
options: CachedEffectOptions<TData> = ({}: $Shape<
|
|
82
|
+
CachedEffectOptions<TData>,
|
|
83
|
+
>),
|
|
84
|
+
): Result<TData> => {
|
|
85
|
+
const {
|
|
86
|
+
skip: hardSkip = false,
|
|
87
|
+
retainResultOnChange = false,
|
|
88
|
+
onResultChanged,
|
|
89
|
+
scope = DefaultScope,
|
|
90
|
+
} = options;
|
|
91
|
+
|
|
92
|
+
// Plug in to the request interception framework for code that wants
|
|
93
|
+
// to use that.
|
|
94
|
+
const interceptedHandler = useRequestInterception(requestId, handler);
|
|
95
|
+
|
|
96
|
+
// Instead of using state, which would be local to just this hook instance,
|
|
97
|
+
// we use a shared in-memory cache.
|
|
98
|
+
const [mostRecentResult, setMostRecentResult] = useSharedCache<
|
|
99
|
+
Result<TData>,
|
|
100
|
+
>(
|
|
101
|
+
requestId, // The key of the cached item
|
|
102
|
+
scope, // The scope of the cached items
|
|
103
|
+
// No default value. We don't want the loading status there; to ensure
|
|
104
|
+
// that all calls when the request is in-flight will update once that
|
|
105
|
+
// request is done, we want the cache to be empty until that point.
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Build a function that will update the cache and either invoke the
|
|
109
|
+
// callback provided in options, or force an update.
|
|
110
|
+
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();
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
[setMostRecentResult, onResultChanged, forceUpdate],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// 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;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If we already have a cached value, we're going to skip.
|
|
146
|
+
if (mostRecentResult != null) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
}, [requestId, previousRequestId, mostRecentResult]);
|
|
152
|
+
|
|
153
|
+
// So now we make sure the client-side request happens per our various
|
|
154
|
+
// options.
|
|
155
|
+
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) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
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
|
+
|
|
192
|
+
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;
|
|
200
|
+
};
|
|
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]);
|
|
208
|
+
|
|
209
|
+
// We track the last result we returned in order to support the
|
|
210
|
+
// "retainResultOnChange" option.
|
|
211
|
+
const lastResultAgnosticOfIdRef = React.useRef(Status.loading());
|
|
212
|
+
const loadingResult = retainResultOnChange
|
|
213
|
+
? lastResultAgnosticOfIdRef.current
|
|
214
|
+
: Status.loading();
|
|
215
|
+
|
|
216
|
+
// Loading is a transient state, so we only use it here; it's not something
|
|
217
|
+
// we cache.
|
|
218
|
+
const result = React.useMemo(
|
|
219
|
+
() => mostRecentResult ?? loadingResult,
|
|
220
|
+
[mostRecentResult, loadingResult],
|
|
221
|
+
);
|
|
222
|
+
lastResultAgnosticOfIdRef.current = result;
|
|
223
|
+
|
|
224
|
+
return result;
|
|
225
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
import {useContext, useRef, useMemo} from "react";
|
|
3
|
+
|
|
4
|
+
import {mergeGqlContext} from "../util/merge-gql-context.js";
|
|
5
|
+
import {GqlRouterContext} from "../util/gql-router-context.js";
|
|
6
|
+
import {GqlError, GqlErrors} from "../util/gql-error.js";
|
|
7
|
+
|
|
8
|
+
import type {GqlRouterConfiguration, GqlContext} from "../util/gql-types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Construct a GqlRouterContext from the current one and partial context.
|
|
12
|
+
*/
|
|
13
|
+
export const useGqlRouterContext = <TContext: GqlContext>(
|
|
14
|
+
contextOverrides: Partial<TContext> = ({}: $Shape<TContext>),
|
|
15
|
+
): GqlRouterConfiguration<TContext> => {
|
|
16
|
+
// This hook only works if the `GqlRouter` has been used to setup context.
|
|
17
|
+
const gqlRouterContext = useContext(GqlRouterContext);
|
|
18
|
+
if (gqlRouterContext == null) {
|
|
19
|
+
throw new GqlError("No GqlRouter", GqlErrors.Internal);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {fetch, defaultContext} = gqlRouterContext;
|
|
23
|
+
const contextRef = useRef<TContext>(defaultContext);
|
|
24
|
+
const mergedContext = mergeGqlContext(defaultContext, contextOverrides);
|
|
25
|
+
|
|
26
|
+
// Now, we can see if this represents a new context and if so,
|
|
27
|
+
// update our ref and return the merged value.
|
|
28
|
+
const refKeys = Object.keys(contextRef.current);
|
|
29
|
+
const mergedKeys = Object.keys(mergedContext);
|
|
30
|
+
const shouldWeUpdateRef =
|
|
31
|
+
refKeys.length !== mergedKeys.length ||
|
|
32
|
+
mergedKeys.every(
|
|
33
|
+
(key) => contextRef.current[key] !== mergedContext[key],
|
|
34
|
+
);
|
|
35
|
+
if (shouldWeUpdateRef) {
|
|
36
|
+
contextRef.current = mergedContext;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// OK, now we're up-to-date, let's memoize our final result.
|
|
40
|
+
const finalContext = contextRef.current;
|
|
41
|
+
const finalRouterContext = useMemo(
|
|
42
|
+
() => ({
|
|
43
|
+
fetch,
|
|
44
|
+
defaultContext: finalContext,
|
|
45
|
+
}),
|
|
46
|
+
[fetch, finalContext],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return finalRouterContext;
|
|
50
|
+
};
|