@khanacademy/wonder-blocks-data 5.0.1 → 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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/es/index.js +771 -372
  3. package/dist/index.js +1191 -550
  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/{components/intercept-requests.md → __docs__/exports.intercept-requests.stories.mdx} +16 -1
  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 +0 -24
  47. package/src/components/__tests__/data.test.js +149 -128
  48. package/src/components/data.js +22 -112
  49. package/src/components/intercept-requests.js +1 -1
  50. package/src/hooks/__tests__/__snapshots__/use-shared-cache.test.js.snap +8 -8
  51. package/src/hooks/__tests__/use-cached-effect.test.js +507 -0
  52. package/src/hooks/__tests__/use-gql-router-context.test.js +133 -0
  53. package/src/hooks/__tests__/use-gql.test.js +1 -30
  54. package/src/hooks/__tests__/use-hydratable-effect.test.js +708 -0
  55. package/src/hooks/__tests__/use-server-effect.test.js +39 -11
  56. package/src/hooks/use-cached-effect.js +225 -0
  57. package/src/hooks/use-gql-router-context.js +50 -0
  58. package/src/hooks/use-gql.js +22 -52
  59. package/src/hooks/use-hydratable-effect.js +206 -0
  60. package/src/hooks/use-request-interception.js +20 -23
  61. package/src/hooks/use-server-effect.js +12 -5
  62. package/src/hooks/use-shared-cache.js +13 -11
  63. package/src/index.js +53 -3
  64. package/src/util/__tests__/__snapshots__/serializable-in-memory-cache.test.js.snap +19 -0
  65. package/src/util/__tests__/merge-gql-context.test.js +74 -0
  66. package/src/util/__tests__/request-fulfillment.test.js +23 -42
  67. package/src/util/__tests__/request-tracking.test.js +26 -7
  68. package/src/util/__tests__/result-from-cache-response.test.js +19 -5
  69. package/src/util/__tests__/scoped-in-memory-cache.test.js +6 -85
  70. package/src/util/__tests__/serializable-in-memory-cache.test.js +398 -0
  71. package/src/util/__tests__/ssr-cache.test.js +52 -52
  72. package/src/util/abort-error.js +15 -0
  73. package/src/util/data-error.js +58 -0
  74. package/src/util/get-gql-data-from-response.js +3 -2
  75. package/src/util/gql-error.js +19 -11
  76. package/src/util/merge-gql-context.js +34 -0
  77. package/src/util/request-fulfillment.js +49 -46
  78. package/src/util/request-tracking.js +69 -15
  79. package/src/util/result-from-cache-response.js +12 -16
  80. package/src/util/scoped-in-memory-cache.js +24 -47
  81. package/src/util/serializable-in-memory-cache.js +49 -0
  82. package/src/util/ssr-cache.js +9 -8
  83. package/src/util/status.js +30 -0
  84. package/src/util/types.js +18 -1
  85. package/docs.md +0 -122
@@ -7,19 +7,29 @@ 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 {clearSharedCache} from "../../hooks/use-shared-cache.js";
10
11
  import TrackData from "../track-data.js";
11
12
  import {RequestFulfillment} from "../../util/request-fulfillment.js";
12
13
  import {SsrCache} from "../../util/ssr-cache.js";
13
14
  import {RequestTracker} from "../../util/request-tracking.js";
14
15
  import InterceptRequests from "../intercept-requests.js";
15
16
  import Data from "../data.js";
17
+ import {
18
+ // TODO(somewhatabstract, FEI-4174): Update eslint-plugin-import when they
19
+ // have fixed:
20
+ // https://github.com/import-js/eslint-plugin-import/issues/2073
21
+ // eslint-disable-next-line import/named
22
+ WhenClientSide,
23
+ } from "../../hooks/use-hydratable-effect.js";
16
24
 
17
25
  describe("Data", () => {
18
26
  beforeEach(() => {
27
+ clearSharedCache();
28
+
19
29
  const responseCache = new SsrCache();
20
30
  jest.spyOn(SsrCache, "Default", "get").mockReturnValue(responseCache);
21
31
  jest.spyOn(RequestFulfillment, "Default", "get").mockReturnValue(
22
- new RequestFulfillment(responseCache),
32
+ new RequestFulfillment(),
23
33
  );
24
34
  jest.spyOn(RequestTracker, "Default", "get").mockReturnValue(
25
35
  new RequestTracker(responseCache),
@@ -35,7 +45,7 @@ describe("Data", () => {
35
45
  jest.spyOn(Server, "isServerSide").mockReturnValue(false);
36
46
  });
37
47
 
38
- describe("without cached data", () => {
48
+ describe("without hydrated data", () => {
39
49
  beforeEach(() => {
40
50
  /**
41
51
  * Each of these test cases will not have cached data to be
@@ -107,7 +117,7 @@ describe("Data", () => {
107
117
  expect(fakeHandler).toHaveBeenCalledTimes(1);
108
118
  });
109
119
 
110
- it("should render with an error if the request rejects to an error", async () => {
120
+ it("should render with an error if the handler request rejects to an error", async () => {
111
121
  // Arrange
112
122
  const fulfillSpy = jest.spyOn(
113
123
  RequestFulfillment.Default,
@@ -122,20 +132,25 @@ describe("Data", () => {
122
132
  {fakeChildrenFn}
123
133
  </Data>,
124
134
  );
135
+
125
136
  /**
126
137
  * We wait for the fulfillment to resolve.
127
138
  */
128
- await act(() => fulfillSpy.mock.results[0].value);
139
+ await act(() =>
140
+ fulfillSpy.mock.results[0].value.catch(() => {
141
+ /* do nothing */
142
+ }),
143
+ );
129
144
 
130
145
  // Assert
131
- expect(fakeChildrenFn).toHaveBeenCalledTimes(2);
132
- expect(fakeChildrenFn).toHaveBeenLastCalledWith({
146
+ expect(fakeChildrenFn).toHaveBeenNthCalledWith(2, {
133
147
  status: "error",
134
- error: "OH NOES!",
148
+ error: expect.any(Error),
135
149
  });
150
+ expect(fakeChildrenFn).toHaveBeenCalledTimes(2);
136
151
  });
137
152
 
138
- it("should render with data if the request resolves with data", async () => {
153
+ it("should render with data if the handler resolves with data", async () => {
139
154
  // Arrange
140
155
  const fulfillSpy = jest.spyOn(
141
156
  RequestFulfillment.Default,
@@ -164,52 +179,68 @@ describe("Data", () => {
164
179
  });
165
180
  });
166
181
 
167
- it.each`
168
- error
169
- ${"CATASTROPHE!"}
170
- ${new Error("CATASTROPHE!")}
171
- `(
172
- "should render with an error if the request rejects with $error",
173
- async ({error}) => {
174
- // Arrange
175
- const fulfillSpy = jest
176
- .spyOn(RequestFulfillment.Default, "fulfill")
177
- .mockReturnValue(Promise.reject(error));
182
+ it("should render with aborted if the request rejects with an abort error", async () => {
183
+ // Arrange
184
+ const fulfillSpy = jest.spyOn(
185
+ RequestFulfillment.Default,
186
+ "fulfill",
187
+ );
178
188
 
179
- const fakeHandler = () => Promise.resolve("YAY!");
180
- const fakeChildrenFn = jest.fn(() => null);
181
- const consoleSpy = jest
182
- .spyOn(console, "error")
183
- .mockImplementation(() => {
184
- /* Just to shut it up */
185
- });
189
+ const abortError = new Error("bang bang, abort!");
190
+ abortError.name = "AbortError";
191
+ const fakeHandler = () => Promise.reject(abortError);
192
+ const fakeChildrenFn = jest.fn(() => null);
186
193
 
187
- // Act
188
- render(
189
- <Data handler={fakeHandler} requestId="ID">
190
- {fakeChildrenFn}
191
- </Data>,
192
- );
193
- /**
194
- * We wait for the fulfillment to reject.
195
- */
196
- await act(() =>
197
- fulfillSpy.mock.results[0].value.catch(() => {}),
198
- );
194
+ // Act
195
+ render(
196
+ <Data handler={fakeHandler} requestId="ID">
197
+ {fakeChildrenFn}
198
+ </Data>,
199
+ );
200
+ /**
201
+ * We wait for the fulfillment to resolve.
202
+ */
203
+ await act(() => fulfillSpy.mock.results[0].value);
199
204
 
200
- // Assert
201
- expect(fakeChildrenFn).toHaveBeenCalledTimes(2);
202
- expect(fakeChildrenFn).toHaveBeenLastCalledWith({
205
+ // Assert
206
+ expect(fakeChildrenFn).toHaveBeenCalledTimes(2);
207
+ expect(fakeChildrenFn).toHaveBeenLastCalledWith({
208
+ status: "aborted",
209
+ });
210
+ });
211
+
212
+ it("should render with an error if the RequestFulfillment rejects with an error", async () => {
213
+ // Arrange
214
+ const fulfillSpy = jest
215
+ .spyOn(RequestFulfillment.Default, "fulfill")
216
+ .mockResolvedValue({
203
217
  status: "error",
204
- error: "CATASTROPHE!",
218
+ error: new Error("CATASTROPHE!"),
205
219
  });
206
- expect(consoleSpy).toHaveBeenCalledWith(
207
- expect.stringMatching(
208
- "Unexpected error occurred during data fulfillment:(?: Error:)? CATASTROPHE!",
209
- ),
210
- );
211
- },
212
- );
220
+
221
+ const fakeHandler = () => Promise.resolve("YAY!");
222
+ const fakeChildrenFn = jest.fn(() => null);
223
+
224
+ // Act
225
+ render(
226
+ <Data handler={fakeHandler} requestId="ID">
227
+ {fakeChildrenFn}
228
+ </Data>,
229
+ );
230
+ /**
231
+ * We wait for the fulfillment to reject.
232
+ */
233
+ await act(() =>
234
+ fulfillSpy.mock.results[0].value.catch(() => {}),
235
+ );
236
+
237
+ // Assert
238
+ expect(fakeChildrenFn).toHaveBeenCalledTimes(2);
239
+ expect(fakeChildrenFn).toHaveBeenLastCalledWith({
240
+ status: "error",
241
+ error: expect.any(Error),
242
+ });
243
+ });
213
244
 
214
245
  it("should start loading if the id changes and request not cached", async () => {
215
246
  // Arrange
@@ -239,12 +270,13 @@ describe("Data", () => {
239
270
  );
240
271
 
241
272
  // Assert
242
- // Render 1: Caused by handler changed
243
- // Render 2: Caused by result state changing to null
244
- expect(fakeChildrenFn).toHaveBeenCalledTimes(2);
273
+ expect(fakeChildrenFn).toHaveBeenCalledTimes(1);
245
274
  expect(fakeChildrenFn).toHaveBeenLastCalledWith({
246
275
  status: "loading",
247
276
  });
277
+
278
+ // We have to do this or testing-library gets very upset.
279
+ await act(() => fulfillSpy.mock.results[0].value);
248
280
  });
249
281
 
250
282
  it("should ignore resolution of pending handler fulfillment when id changes", async () => {
@@ -319,7 +351,10 @@ describe("Data", () => {
319
351
 
320
352
  it("should ignore catastrophic request fulfillment when id changes", async () => {
321
353
  // Arrange
322
- const catastrophe = Promise.reject("CATASTROPHE!");
354
+ const catastrophe = Promise.resolve({
355
+ status: "error",
356
+ error: new Error("CATASTROPHE!"),
357
+ });
323
358
  jest.spyOn(
324
359
  RequestFulfillment.Default,
325
360
  "fulfill",
@@ -347,12 +382,12 @@ describe("Data", () => {
347
382
  // Assert
348
383
  expect(fakeChildrenFn).not.toHaveBeenCalledWith({
349
384
  status: "error",
350
- error: "CATASTROPHE!",
385
+ error: expect.any(Error),
351
386
  });
352
387
  });
353
388
 
354
389
  describe("with data interceptor", () => {
355
- it("should request data from interceptor", () => {
390
+ it("should request data from interceptor", async () => {
356
391
  // Arrange
357
392
  const fakeHandler = jest.fn().mockResolvedValue("data");
358
393
  const fakeChildrenFn = jest.fn(() => null);
@@ -368,13 +403,14 @@ describe("Data", () => {
368
403
  </Data>
369
404
  </InterceptRequests>,
370
405
  );
406
+ await act(() => interceptHandler.mock.results[0].value);
371
407
 
372
408
  // Assert
373
409
  expect(interceptHandler).toHaveBeenCalledTimes(1);
374
410
  expect(fakeHandler).not.toHaveBeenCalled();
375
411
  });
376
412
 
377
- it("should invoke handler method if interceptor method returns null", () => {
413
+ it("should invoke handler method if interceptor method returns null", async () => {
378
414
  // Arrange
379
415
  const fakeHandler = jest.fn().mockResolvedValue("data");
380
416
  const fakeChildrenFn = jest.fn(() => null);
@@ -388,50 +424,15 @@ describe("Data", () => {
388
424
  </Data>
389
425
  </InterceptRequests>,
390
426
  );
427
+ await act(() => fakeHandler.mock.results[0].value);
391
428
 
392
429
  // Assert
393
430
  expect(interceptHandler).toHaveBeenCalledTimes(1);
394
431
  expect(fakeHandler).toHaveBeenCalledTimes(1);
395
432
  });
396
433
  });
397
- });
398
-
399
- describe("with cache data", () => {
400
- beforeEach(() => {
401
- /**
402
- * Each of these test cases will start out with some cached data
403
- * retrieved.
404
- */
405
- jest.spyOn(
406
- SsrCache.Default,
407
- "getEntry",
408
- // Fake once because that's how the cache would work,
409
- // deleting the hydrated value as soon as it was used.
410
- ).mockReturnValueOnce({
411
- data: "YAY! DATA!",
412
- });
413
- });
414
434
 
415
- it("should render first time with the cached data", () => {
416
- // Arrange
417
- const fakeHandler = () => Promise.resolve("data");
418
- const fakeChildrenFn = jest.fn(() => null);
419
-
420
- // Act
421
- render(
422
- <Data handler={fakeHandler} requestId="ID">
423
- {fakeChildrenFn}
424
- </Data>,
425
- );
426
-
427
- // Assert
428
- expect(fakeChildrenFn).toHaveBeenCalledWith({
429
- status: "success",
430
- data: "YAY! DATA!",
431
- });
432
- });
433
-
434
- it("should retain old data while reloading if showOldDataWhileLoading is true", async () => {
435
+ it("should retain old data while reloading if retainResultOnChange is true", async () => {
435
436
  // Arrange
436
437
  const response1 = Promise.resolve("data1");
437
438
  const response2 = Promise.resolve("data2");
@@ -443,49 +444,76 @@ describe("Data", () => {
443
444
  const wrapper = render(
444
445
  <Data
445
446
  handler={fakeHandler1}
446
- requestId="ID"
447
- showOldDataWhileLoading={false}
447
+ requestId="ID1"
448
+ retainResultOnChange={true}
448
449
  >
449
450
  {fakeChildrenFn}
450
451
  </Data>,
451
452
  );
453
+ fakeChildrenFn.mockClear();
454
+ await act(() => response1);
452
455
  wrapper.rerender(
453
456
  <Data
454
457
  handler={fakeHandler2}
455
- requestId="ID"
456
- showOldDataWhileLoading={true}
458
+ requestId="ID2"
459
+ retainResultOnChange={true}
457
460
  >
458
461
  {fakeChildrenFn}
459
462
  </Data>,
460
463
  );
464
+ await act(() => response2);
461
465
 
462
466
  // Assert
463
467
  expect(fakeChildrenFn).not.toHaveBeenCalledWith({
464
468
  status: "loading",
465
469
  });
470
+ expect(fakeChildrenFn).toHaveBeenCalledWith({
471
+ status: "success",
472
+ data: "data1",
473
+ });
474
+ expect(fakeChildrenFn).toHaveBeenLastCalledWith({
475
+ status: "success",
476
+ data: "data2",
477
+ });
466
478
  });
479
+ });
467
480
 
468
- it("should not request data when alwaysRequestOnHydration is false and cache has a valid data result", () => {
481
+ describe("with hydrated data", () => {
482
+ beforeEach(() => {
483
+ /**
484
+ * Each of these test cases will start out with some cached data
485
+ * retrieved.
486
+ */
487
+ jest.spyOn(
488
+ SsrCache.Default,
489
+ "getEntry",
490
+ // Fake once because that's how the cache would work,
491
+ // deleting the hydrated value as soon as it was used.
492
+ ).mockReturnValueOnce({
493
+ data: "YAY! DATA!",
494
+ });
495
+ });
496
+
497
+ it("should render first time with the cached data", () => {
469
498
  // Arrange
470
- const fakeHandler = jest.fn().mockResolvedValue("data");
499
+ const fakeHandler = () => Promise.resolve("data");
471
500
  const fakeChildrenFn = jest.fn(() => null);
472
501
 
473
502
  // Act
474
503
  render(
475
- <Data
476
- handler={fakeHandler}
477
- requestId="ID"
478
- alwaysRequestOnHydration={false}
479
- >
504
+ <Data handler={fakeHandler} requestId="ID">
480
505
  {fakeChildrenFn}
481
506
  </Data>,
482
507
  );
483
508
 
484
509
  // Assert
485
- expect(fakeHandler).not.toHaveBeenCalled();
510
+ expect(fakeChildrenFn).toHaveBeenCalledWith({
511
+ status: "success",
512
+ data: "YAY! DATA!",
513
+ });
486
514
  });
487
515
 
488
- it("should request data if cached data value is valid but alwaysRequestOnHydration is true", () => {
516
+ it("should not request data when clientBehavior is WhenClientSide.ExecuteWhenNoSuccessResult and cache has a valid success result", () => {
489
517
  // Arrange
490
518
  const fakeHandler = jest.fn().mockResolvedValue("data");
491
519
  const fakeChildrenFn = jest.fn(() => null);
@@ -495,43 +523,34 @@ describe("Data", () => {
495
523
  <Data
496
524
  handler={fakeHandler}
497
525
  requestId="ID"
498
- alwaysRequestOnHydration={true}
526
+ clientBehavior={
527
+ WhenClientSide.ExecuteWhenNoSuccessResult
528
+ }
499
529
  >
500
530
  {fakeChildrenFn}
501
531
  </Data>,
502
532
  );
503
533
 
504
534
  // Assert
505
- expect(fakeHandler).toHaveBeenCalledTimes(1);
506
- });
507
- });
508
-
509
- describe("with cached abort", () => {
510
- beforeEach(() => {
511
- /**
512
- * Each of these test cases will start out with a cached abort.
513
- */
514
- jest.spyOn(
515
- SsrCache.Default,
516
- "getEntry",
517
- // Fake once because that's how the cache would work,
518
- // deleting the hydrated value as soon as it was used.
519
- ).mockReturnValueOnce({
520
- data: null,
521
- });
535
+ expect(fakeHandler).not.toHaveBeenCalled();
522
536
  });
523
537
 
524
- it("should request data if cached data value is null (i.e. represents an aborted request)", () => {
538
+ it("should request data if cached data value is valid but clientBehavior is WhenClientSide.AlwaysExecute is true", async () => {
525
539
  // Arrange
526
540
  const fakeHandler = jest.fn().mockResolvedValue("data");
527
541
  const fakeChildrenFn = jest.fn(() => null);
528
542
 
529
543
  // Act
530
544
  render(
531
- <Data handler={fakeHandler} requestId="ID">
545
+ <Data
546
+ handler={fakeHandler}
547
+ requestId="ID"
548
+ clientBehavior={WhenClientSide.AlwaysExecute}
549
+ >
532
550
  {fakeChildrenFn}
533
551
  </Data>,
534
552
  );
553
+ await act(() => fakeHandler.mock.results[0].value);
535
554
 
536
555
  // Assert
537
556
  expect(fakeHandler).toHaveBeenCalledTimes(1);
@@ -553,7 +572,7 @@ describe("Data", () => {
553
572
  });
554
573
  });
555
574
 
556
- it("should always request data if there's a cached error", () => {
575
+ it("should always request data if there's a cached error", async () => {
557
576
  // Arrange
558
577
  const fakeHandler = jest.fn().mockResolvedValue("data");
559
578
  const fakeChildrenFn = jest.fn(() => null);
@@ -564,6 +583,8 @@ describe("Data", () => {
564
583
  {fakeChildrenFn}
565
584
  </Data>,
566
585
  );
586
+ // Have to await the promise in an act to keep TL/R happy.
587
+ await act(() => fakeHandler.mock.results[0].value);
567
588
 
568
589
  // Assert
569
590
  expect(fakeHandler).toHaveBeenCalledTimes(1);
@@ -765,7 +786,7 @@ describe("Data", () => {
765
786
  // Assert
766
787
  expect(fakeChildrenFn).toHaveBeenCalledWith({
767
788
  status: "error",
768
- error: "OH NO! IT GO BOOM",
789
+ error: expect.any(Error),
769
790
  });
770
791
  });
771
792
 
@@ -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 {useServerEffect} from "../hooks/use-server-effect.js";
7
- import {useRequestInterception} from "../hooks/use-request-interception.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,97 +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
- const interceptedHandler = useRequestInterception(requestId, handler);
84
-
85
- const hydrateResult = useServerEffect(
86
- requestId,
87
- interceptedHandler,
88
- hydrate,
89
- );
90
- const [currentResult, setResult] = React.useState(hydrateResult);
91
-
92
- // Here we make sure the request still occurs client-side as needed.
93
- // This is for legacy usage that expects this. Eventually we will want
94
- // to deprecate.
95
- React.useEffect(() => {
96
- // This is here until I can do a better documentation example for
97
- // the TrackData docs.
98
- // istanbul ignore next
99
- if (Server.isServerSide()) {
100
- return;
101
- }
102
-
103
- // We don't bother with this if we have hydration data and we're not
104
- // forcing a request on hydration.
105
- // We don't care if these things change after the first render,
106
- // so we don't want them in the inputs array.
107
- if (!alwaysRequestOnHydration && hydrateResult?.data != null) {
108
- return;
109
- }
110
-
111
- // If we're not hydrating a result and we're not going to render
112
- // with old data until we're loaded, we want to make sure we set our
113
- // result to null so that we're in the loading state.
114
- if (!showOldDataWhileLoading) {
115
- // Mark ourselves as loading.
116
- setResult(null);
117
- }
118
-
119
- // We aren't server-side, so let's make the request.
120
- // We don't need to use our built-in request fulfillment here if we
121
- // don't want, but it does mean we'll share inflight requests for the
122
- // same ID and the result will be in the same format as the
123
- // hydrated value.
124
- let cancel = false;
125
- RequestFulfillment.Default.fulfill(requestId, {
126
- handler: interceptedHandler,
127
- })
128
- .then((result) => {
129
- if (cancel) {
130
- return;
131
- }
132
- setResult(result);
133
- return;
134
- })
135
- .catch((e) => {
136
- if (cancel) {
137
- return;
138
- }
139
- /**
140
- * We should never get here as errors in fulfillment are part
141
- * of the `then`, but if we do.
142
- */
143
- // eslint-disable-next-line no-console
144
- console.error(
145
- `Unexpected error occurred during data fulfillment: ${e}`,
146
- );
147
- setResult({
148
- error: typeof e === "string" ? e : e.message,
149
- });
150
- return;
151
- });
152
-
153
- return () => {
154
- cancel = true;
155
- };
156
- // If the handler changes, we don't care. The ID is what indicates
157
- // the request that should be made and folks shouldn't be changing the
158
- // handler without changing the ID as well.
159
- // In addition, we don't want to include hydrateResult nor
160
- // alwaysRequestOnHydration as them changinng after the first pass
161
- // is irrelevant.
162
- // Finally, we don't want to include showOldDataWhileLoading as that
163
- // changing on its own is also not relevant. It only matters if the
164
- // request itself changes. All of which is to say that we only
165
- // run this effect for the ID changing.
166
- // eslint-disable-next-line react-hooks/exhaustive-deps
167
- }, [requestId]);
168
-
169
- return children(resultFromCachedResponse(currentResult));
75
+ const result = useHydratableEffect(requestId, handler, {
76
+ retainResultOnChange,
77
+ clientBehavior,
78
+ });
79
+ return children(result);
170
80
  };
171
81
 
172
82
  export default Data;
@@ -20,7 +20,7 @@ type Props<TData: ValidCacheData> = {|
20
20
  * so make sure to only intercept requests that you recognize from the
21
21
  * identifier.
22
22
  */
23
- interceptor: (requestId: string) => ?Promise<?TData>,
23
+ interceptor: (requestId: string) => ?Promise<TData>,
24
24
 
25
25
  /**
26
26
  * The children to render within this component. Any requests by `Data`
@@ -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]`;