1
+let React;
2
+let ReactNoop;
3
+let Scheduler;
4
+let act;
5
+let assertLog;
6
+let useTransition;
7
+let useState;
8
+let textCache;
9
+
10
+describe('ReactAsyncActions', () => {
11
+ beforeEach(() => {
12
+ jest.resetModules();
13
+
14
+ React = require('react');
15
+ ReactNoop = require('react-noop-renderer');
16
+ Scheduler = require('scheduler');
17
+ act = require('internal-test-utils').act;
18
+ assertLog = require('internal-test-utils').assertLog;
19
+ useTransition = React.useTransition;
20
+ useState = React.useState;
21
+
22
+ textCache = new Map();
23
+ });
24
+
25
+ function resolveText(text) {
26
+ const record = textCache.get(text);
27
+ if (record === undefined) {
28
+ const newRecord = {
29
+ status: 'resolved',
30
+ value: text,
31
+ };
32
+ textCache.set(text, newRecord);
33
+ } else if (record.status === 'pending') {
34
+ const thenable = record.value;
35
+ record.status = 'resolved';
36
+ record.value = text;
37
+ thenable.pings.forEach(t => t());
38
+ }
39
+ }
40
+
41
+ function readText(text) {
42
+ const record = textCache.get(text);
43
+ if (record !== undefined) {
44
+ switch (record.status) {
45
+ case 'pending':
46
+ Scheduler.log(`Suspend! [${text}]`);
47
+ throw record.value;
48
+ case 'rejected':
49
+ throw record.value;
50
+ case 'resolved':
51
+ return record.value;
52
+ }
53
+ } else {
54
+ Scheduler.log(`Suspend! [${text}]`);
55
+ const thenable = {
56
+ pings: [],
57
+ then(resolve) {
58
+ if (newRecord.status === 'pending') {
59
+ thenable.pings.push(resolve);
60
+ } else {
61
+ Promise.resolve().then(() => resolve(newRecord.value));
62
+ }
63
+ },
64
+ };
65
+
66
+ const newRecord = {
67
+ status: 'pending',
68
+ value: thenable,
69
+ };
70
+ textCache.set(text, newRecord);
71
+
72
+ throw thenable;
73
+ }
74
+ }
75
+
76
+ function getText(text) {
77
+ const record = textCache.get(text);
78
+ if (record === undefined) {
79
+ const thenable = {
80
+ pings: [],
81
+ then(resolve) {
82
+ if (newRecord.status === 'pending') {
83
+ thenable.pings.push(resolve);
84
+ } else {
85
+ Promise.resolve().then(() => resolve(newRecord.value));
86
+ }
87
+ },
88
+ };
89
+ const newRecord = {
90
+ status: 'pending',
91
+ value: thenable,
92
+ };
93
+ textCache.set(text, newRecord);
94
+ return thenable;
95
+ } else {
96
+ switch (record.status) {
97
+ case 'pending':
98
+ return record.value;
99
+ case 'rejected':
100
+ return Promise.reject(record.value);
101
+ case 'resolved':
102
+ return Promise.resolve(record.value);
103
+ }
104
+ }
105
+ }
106
+
107
+ function Text({text}) {
108
+ Scheduler.log(text);
109
+ return text;
110
+ }
111
+
112
+ function AsyncText({text}) {
113
+ readText(text);
114
+ Scheduler.log(text);
115
+ return text;
116
+ }
117
+
118
+ // @gate enableAsyncActions
119
+ test('isPending remains true until async action finishes', async () => {
120
+ let startTransition;
121
+ function App() {
122
+ const [isPending, _start] = useTransition();
123
+ startTransition = _start;
124
+ return <Text text={'Pending: ' + isPending} />;
125
+ }
126
+
127
+ const root = ReactNoop.createRoot();
128
+ await act(() => {
129
+ root.render(<App />);
130
+ });
131
+ assertLog(['Pending: false']);
132
+ expect(root).toMatchRenderedOutput('Pending: false');
133
+
134
+ // At the start of an async action, isPending is set to true.
135
+ await act(() => {
136
+ startTransition(async () => {
137
+ Scheduler.log('Async action started');
138
+ await getText('Wait');
139
+ Scheduler.log('Async action ended');
140
+ });
141
+ });
142
+ assertLog(['Async action started', 'Pending: true']);
143
+ expect(root).toMatchRenderedOutput('Pending: true');
144
+
145
+ // Once the action finishes, isPending is set back to false.
146
+ await act(() => resolveText('Wait'));
147
+ assertLog(['Async action ended', 'Pending: false']);
148
+ expect(root).toMatchRenderedOutput('Pending: false');
149
+ });
150
+
151
+ // @gate enableAsyncActions
152
+ test('multiple updates in an async action scope are entangled together', async () => {
153
+ let startTransition;
154
+ function App({text}) {
155
+ const [isPending, _start] = useTransition();
156
+ startTransition = _start;
157
+ return (
158
+ <>
159
+ <span>
160
+ <Text text={'Pending: ' + isPending} />
161
+ </span>
162
+ <span>
163
+ <Text text={text} />
164
+ </span>
165
+ </>
166
+ );
167
+ }
168
+
169
+ const root = ReactNoop.createRoot();
170
+ await act(() => {
171
+ root.render(<App text="A" />);
172
+ });
173
+ assertLog(['Pending: false', 'A']);
174
+ expect(root).toMatchRenderedOutput(
175
+ <>
176
+ <span>Pending: false</span>
177
+ <span>A</span>
178
+ </>,
179
+ );
180
+
181
+ await act(() => {
182
+ startTransition(async () => {
183
+ Scheduler.log('Async action started');
184
+ await getText('Yield before updating');
185
+ Scheduler.log('Async action ended');
186
+ startTransition(() => root.render(<App text="B" />));
187
+ });
188
+ });
189
+ assertLog(['Async action started', 'Pending: true', 'A']);
190
+ expect(root).toMatchRenderedOutput(
191
+ <>
192
+ <span>Pending: true</span>
193
+ <span>A</span>
194
+ </>,
195
+ );
196
+
197
+ await act(() => resolveText('Yield before updating'));
198
+ assertLog(['Async action ended', 'Pending: false', 'B']);
199
+ expect(root).toMatchRenderedOutput(
200
+ <>
201
+ <span>Pending: false</span>
202
+ <span>B</span>
203
+ </>,
204
+ );
205
+ });
206
+
207
+ // @gate enableAsyncActions
208
+ test('multiple async action updates in the same scope are entangled together', async () => {
209
+ let setStepA;
210
+ function A() {
211
+ const [step, setStep] = useState(0);
212
+ setStepA = setStep;
213
+ return <AsyncText text={'A' + step} />;
214
+ }
215
+
216
+ let setStepB;
217
+ function B() {
218
+ const [step, setStep] = useState(0);
219
+ setStepB = setStep;
220
+ return <AsyncText text={'B' + step} />;
221
+ }
222
+
223
+ let setStepC;
224
+ function C() {
225
+ const [step, setStep] = useState(0);
226
+ setStepC = setStep;
227
+ return <AsyncText text={'C' + step} />;
228
+ }
229
+
230
+ let startTransition;
231
+ function App() {
232
+ const [isPending, _start] = useTransition();
233
+ startTransition = _start;
234
+ return (
235
+ <>
236
+ <span>
237
+ <Text text={'Pending: ' + isPending} />
238
+ </span>
239
+ <span>
240
+ <A />, <B />, <C />
241
+ </span>
242
+ </>
243
+ );
244
+ }
245
+
246
+ const root = ReactNoop.createRoot();
247
+ resolveText('A0');
248
+ resolveText('B0');
249
+ resolveText('C0');
250
+ await act(() => {
251
+ root.render(<App text="A" />);
252
+ });
253
+ assertLog(['Pending: false', 'A0', 'B0', 'C0']);
254
+ expect(root).toMatchRenderedOutput(
255
+ <>
256
+ <span>Pending: false</span>
257
+ <span>A0, B0, C0</span>
258
+ </>,
259
+ );
260
+
261
+ await act(() => {
262
+ startTransition(async () => {
263
+ Scheduler.log('Async action started');
264
+ setStepA(1);
265
+ await getText('Wait before updating B');
266
+ startTransition(() => setStepB(1));
267
+ await getText('Wait before updating C');
268
+ startTransition(() => setStepC(1));
269
+ Scheduler.log('Async action ended');
270
+ });
271
+ });
272
+ assertLog(['Async action started', 'Pending: true', 'A0', 'B0', 'C0']);
273
+ expect(root).toMatchRenderedOutput(
274
+ <>
275
+ <span>Pending: true</span>
276
+ <span>A0, B0, C0</span>
277
+ </>,
278
+ );
279
+
280
+ // This will schedule an update on B, but nothing will render yet because
281
+ // the async action scope hasn't finished.
282
+ await act(() => resolveText('Wait before updating B'));
283
+ assertLog([]);
284
+ expect(root).toMatchRenderedOutput(
285
+ <>
286
+ <span>Pending: true</span>
287
+ <span>A0, B0, C0</span>
288
+ </>,
289
+ );
290
+
291
+ // This will schedule an update on C, and also the async action scope
292
+ // will end. This will allow React to attempt to render the updates.
293
+ await act(() => resolveText('Wait before updating C'));
294
+ assertLog(['Async action ended', 'Pending: false', 'Suspend! [A1]']);
295
+ expect(root).toMatchRenderedOutput(
296
+ <>
297
+ <span>Pending: true</span>
298
+ <span>A0, B0, C0</span>
299
+ </>,
300
+ );
301
+
302
+ // Progressively load the all the data. Because they are all entangled
303
+ // together, only when the all of A, B, and C updates are unblocked is the
304
+ // render allowed to proceed.
305
+ await act(() => resolveText('A1'));
306
+ assertLog(['Pending: false', 'A1', 'Suspend! [B1]']);
307
+ expect(root).toMatchRenderedOutput(
308
+ <>
309
+ <span>Pending: true</span>
310
+ <span>A0, B0, C0</span>
311
+ </>,
312
+ );
313
+ await act(() => resolveText('B1'));
314
+ assertLog(['Pending: false', 'A1', 'B1', 'Suspend! [C1]']);
315
+ expect(root).toMatchRenderedOutput(
316
+ <>
317
+ <span>Pending: true</span>
318
+ <span>A0, B0, C0</span>
319
+ </>,
320
+ );
321
+
322
+ // Finally, all the data has loaded and the transition is complete.
323
+ await act(() => resolveText('C1'));
324
+ assertLog(['Pending: false', 'A1', 'B1', 'C1']);
325
+ expect(root).toMatchRenderedOutput(
326
+ <>
327
+ <span>Pending: false</span>
328
+ <span>A1, B1, C1</span>
329
+ </>,
330
+ );
331
+ });
332
+
333
+ // @gate enableAsyncActions
334
+ test('urgent updates are not blocked during an async action', async () => {
335
+ let setStepA;
336
+ function A() {
337
+ const [step, setStep] = useState(0);
338
+ setStepA = setStep;
339
+ return <Text text={'A' + step} />;
340
+ }
341
+
342
+ let setStepB;
343
+ function B() {
344
+ const [step, setStep] = useState(0);
345
+ setStepB = setStep;
346
+ return <Text text={'B' + step} />;
347
+ }
348
+
349
+ let startTransition;
350
+ function App() {
351
+ const [isPending, _start] = useTransition();
352
+ startTransition = _start;
353
+ return (
354
+ <>
355
+ <span>
356
+ <Text text={'Pending: ' + isPending} />
357
+ </span>
358
+ <span>
359
+ <A />, <B />
360
+ </span>
361
+ </>
362
+ );
363
+ }
364
+
365
+ const root = ReactNoop.createRoot();
366
+ await act(() => {
367
+ root.render(<App text="A" />);
368
+ });
369
+ assertLog(['Pending: false', 'A0', 'B0']);
370
+ expect(root).toMatchRenderedOutput(
371
+ <>
372
+ <span>Pending: false</span>
373
+ <span>A0, B0</span>
374
+ </>,
375
+ );
376
+
377
+ await act(() => {
378
+ startTransition(async () => {
379
+ Scheduler.log('Async action started');
380
+ startTransition(() => setStepA(1));
381
+ await getText('Wait');
382
+ Scheduler.log('Async action ended');
383
+ });
384
+ });
385
+ assertLog(['Async action started', 'Pending: true', 'A0', 'B0']);
386
+ expect(root).toMatchRenderedOutput(
387
+ <>
388
+ <span>Pending: true</span>
389
+ <span>A0, B0</span>
390
+ </>,
391
+ );
392
+
393
+ // Update B at urgent priority. This should be allowed to finish.
394
+ await act(() => setStepB(1));
395
+ assertLog(['B1']);
396
+ expect(root).toMatchRenderedOutput(
397
+ <>
398
+ <span>Pending: true</span>
399
+ <span>A0, B1</span>
400
+ </>,
401
+ );
402
+
403
+ // Finish the async action.
404
+ await act(() => resolveText('Wait'));
405
+ assertLog(['Async action ended', 'Pending: false', 'A1', 'B1']);
406
+ expect(root).toMatchRenderedOutput(
407
+ <>
408
+ <span>Pending: false</span>
409
+ <span>A1, B1</span>
410
+ </>,
411
+ );
412
+ });
413
+
414
+ // @gate enableAsyncActions
415
+ test("if a sync action throws, it's rethrown from the `useTransition`", async () => {
416
+ class ErrorBoundary extends React.Component {
417
+ state = {error: null};
418
+ static getDerivedStateFromError(error) {
419
+ return {error};
420
+ }
421
+ render() {
422
+ if (this.state.error) {
423
+ return <Text text={this.state.error.message} />;
424
+ }
425
+ return this.props.children;
426
+ }
427
+ }
428
+
429
+ let startTransition;
430
+ function App() {
431
+ const [isPending, _start] = useTransition();
432
+ startTransition = _start;
433
+ return <Text text={'Pending: ' + isPending} />;
434
+ }
435
+
436
+ const root = ReactNoop.createRoot();
437
+ await act(() => {
438
+ root.render(
439
+ <ErrorBoundary>
440
+ <App />
441
+ </ErrorBoundary>,
442
+ );
443
+ });
444
+ assertLog(['Pending: false']);
445
+ expect(root).toMatchRenderedOutput('Pending: false');
446
+
447
+ await act(() => {
448
+ startTransition(() => {
449
+ throw new Error('Oops!');
450
+ });
451
+ });
452
+ assertLog(['Pending: true', 'Oops!', 'Oops!']);
453
+ expect(root).toMatchRenderedOutput('Oops!');
454
+ });
455
+
456
+ // @gate enableAsyncActions
457
+ test("if an async action throws, it's rethrown from the `useTransition`", async () => {
458
+ class ErrorBoundary extends React.Component {
459
+ state = {error: null};
460
+ static getDerivedStateFromError(error) {
461
+ return {error};
462
+ }
463
+ render() {
464
+ if (this.state.error) {
465
+ return <Text text={this.state.error.message} />;
466
+ }
467
+ return this.props.children;
468
+ }
469
+ }
470
+
471
+ let startTransition;
472
+ function App() {
473
+ const [isPending, _start] = useTransition();
474
+ startTransition = _start;
475
+ return <Text text={'Pending: ' + isPending} />;
476
+ }
477
+
478
+ const root = ReactNoop.createRoot();
479
+ await act(() => {
480
+ root.render(
481
+ <ErrorBoundary>
482
+ <App />
483
+ </ErrorBoundary>,
484
+ );
485
+ });
486
+ assertLog(['Pending: false']);
487
+ expect(root).toMatchRenderedOutput('Pending: false');
488
+
489
+ await act(() => {
490
+ startTransition(async () => {
491
+ Scheduler.log('Async action started');
492
+ await getText('Wait');
493
+ throw new Error('Oops!');
494
+ });
495
+ });
496
+ assertLog(['Async action started', 'Pending: true']);
497
+ expect(root).toMatchRenderedOutput('Pending: true');
498
+
499
+ await act(() => resolveText('Wait'));
500
+ assertLog(['Oops!', 'Oops!']);
501
+ expect(root).toMatchRenderedOutput('Oops!');
502
+ });
503
+
504
+ // @gate !enableAsyncActions
505
+ test('when enableAsyncActions is disabled, and a sync action throws, `isPending` is turned off', async () => {
506
+ let startTransition;
507
+ function App() {
508
+ const [isPending, _start] = useTransition();
509
+ startTransition = _start;
510
+ return <Text text={'Pending: ' + isPending} />;
511
+ }
512
+
513
+ const root = ReactNoop.createRoot();
514
+ await act(() => {
515
+ root.render(<App />);
516
+ });
517
+ assertLog(['Pending: false']);
518
+ expect(root).toMatchRenderedOutput('Pending: false');
519
+
520
+ await act(() => {
521
+ expect(() => {
522
+ startTransition(() => {
523
+ throw new Error('Oops!');
524
+ });
525
+ }).toThrow('Oops!');
526
+ });
527
+ assertLog(['Pending: true', 'Pending: false']);
528
+ expect(root).toMatchRenderedOutput('Pending: false');
529
+ });
530
+});