| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @emails react-core |
| 8 | */ |
| 9 | |
| 10 | let React; |
| 11 | let ReactDOMClient; |
| 12 | let Scheduler; |
| 13 | let act; |
| 14 | let container; |
| 15 | let assertLog; |
| 16 | let assertConsoleErrorDev; |
| 17 | |
| 18 | jest.useRealTimers(); |
| 19 | |
| 20 | global.IS_REACT_ACT_ENVIRONMENT = true; |
| 21 | |
| 22 | function sleep(period) { |
| 23 | return new Promise(resolve => { |
| 24 | setTimeout(() => { |
| 25 | resolve(true); |
| 26 | }, period); |
| 27 | }); |
| 28 | } |
| 29 | |
| 30 | describe('React.act()', () => { |
| 31 | afterEach(() => { |
| 32 | jest.restoreAllMocks(); |
| 33 | }); |
| 34 | |
| 35 | let root = null; |
| 36 | const renderConcurrent = (el, dom) => { |
| 37 | root = ReactDOMClient.createRoot(dom); |
| 38 | if (__DEV__) { |
| 39 | act(() => root.render(el)); |
| 40 | } else { |
| 41 | root.render(el); |
| 42 | } |
| 43 | }; |
| 44 | |
| 45 | const unmountConcurrent = _dom => { |
| 46 | if (__DEV__) { |
| 47 | act(() => { |
| 48 | if (root !== null) { |
| 49 | root.unmount(); |
| 50 | root = null; |
| 51 | } |
| 52 | }); |
| 53 | } else { |
| 54 | if (root !== null) { |
| 55 | root.unmount(); |
| 56 | root = null; |
| 57 | } |
| 58 | } |
| 59 | }; |
| 60 | |
| 61 | const rerenderConcurrent = el => { |
| 62 | act(() => root.render(el)); |
| 63 | }; |
| 64 | |
| 65 | runActTests(renderConcurrent, unmountConcurrent, rerenderConcurrent); |
| 66 | |
| 67 | describe('unacted effects', () => { |
| 68 | function App() { |
| 69 | React.useEffect(() => {}, []); |
| 70 | return null; |
| 71 | } |
| 72 | |
| 73 | // @gate __DEV__ |
| 74 | it('does not warn', () => { |
| 75 | root = ReactDOMClient.createRoot(document.createElement('div')); |
| 76 | act(() => root.render(<App />)); |
| 77 | }); |
| 78 | }); |
| 79 | }); |
| 80 | |
| 81 | function runActTests(render, unmount, rerender) { |
| 82 | describe('concurrent render', () => { |
| 83 | beforeEach(() => { |
| 84 | jest.resetModules(); |
| 85 | React = require('react'); |
| 86 | ReactDOMClient = require('react-dom/client'); |
| 87 | Scheduler = require('scheduler'); |
| 88 | act = React.act; |
| 89 | |
| 90 | const InternalTestUtils = require('internal-test-utils'); |
| 91 | assertLog = InternalTestUtils.assertLog; |
| 92 | assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev; |
| 93 | |
| 94 | container = document.createElement('div'); |
| 95 | document.body.appendChild(container); |
| 96 | }); |
| 97 | |
| 98 | afterEach(() => { |
| 99 | unmount(container); |
| 100 | document.body.removeChild(container); |
| 101 | }); |
| 102 | |
| 103 | describe('sync', () => { |
| 104 | // @gate __DEV__ |
| 105 | it('can use act to flush effects', () => { |
| 106 | function App() { |
| 107 | React.useEffect(() => { |
| 108 | Scheduler.log(100); |
| 109 | }); |
| 110 | return null; |
| 111 | } |
| 112 | |
| 113 | act(() => { |
| 114 | render(<App />, container); |
| 115 | }); |
| 116 | |
| 117 | assertLog([100]); |
| 118 | }); |
| 119 | |
| 120 | // @gate __DEV__ |
| 121 | it('flushes effects on every call', async () => { |
| 122 | function App() { |
| 123 | const [ctr, setCtr] = React.useState(0); |
| 124 | React.useEffect(() => { |
| 125 | Scheduler.log(ctr); |
| 126 | }); |
| 127 | return ( |
| 128 | <button id="button" onClick={() => setCtr(x => x + 1)}> |
| 129 | {ctr} |
| 130 | </button> |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | act(() => { |
| 135 | render(<App />, container); |
| 136 | }); |
| 137 | assertLog([0]); |
| 138 | const button = container.querySelector('#button'); |
| 139 | function click() { |
| 140 | button.dispatchEvent(new MouseEvent('click', {bubbles: true})); |
| 141 | } |
| 142 | |
| 143 | await act(async () => { |
| 144 | click(); |
| 145 | click(); |
| 146 | click(); |
| 147 | }); |
| 148 | // it consolidates the 3 updates, then fires the effect |
| 149 | assertLog([3]); |
| 150 | await act(async () => click()); |
| 151 | assertLog([4]); |
| 152 | await act(async () => click()); |
| 153 | assertLog([5]); |
| 154 | expect(button.innerHTML).toBe('5'); |
| 155 | }); |
| 156 | |
| 157 | // @gate __DEV__ |
| 158 | it("should keep flushing effects until they're done", () => { |
| 159 | function App() { |
| 160 | const [ctr, setCtr] = React.useState(0); |
| 161 | React.useEffect(() => { |
| 162 | if (ctr < 5) { |
| 163 | setCtr(x => x + 1); |
| 164 | } |
| 165 | }); |
| 166 | return ctr; |
| 167 | } |
| 168 | |
| 169 | act(() => { |
| 170 | render(<App />, container); |
| 171 | }); |
| 172 | |
| 173 | expect(container.innerHTML).toBe('5'); |
| 174 | }); |
| 175 | |
| 176 | // @gate __DEV__ |
| 177 | it('should flush effects only on exiting the outermost act', () => { |
| 178 | function App() { |
| 179 | React.useEffect(() => { |
| 180 | Scheduler.log(0); |
| 181 | }); |
| 182 | return null; |
| 183 | } |
| 184 | // let's nest a couple of act() calls |
| 185 | act(() => { |
| 186 | act(() => { |
| 187 | render(<App />, container); |
| 188 | }); |
| 189 | // the effect wouldn't have yielded yet because |
| 190 | // we're still inside an act() scope |
| 191 | assertLog([]); |
| 192 | }); |
| 193 | // but after exiting the last one, effects get flushed |
| 194 | assertLog([0]); |
| 195 | }); |
| 196 | |
| 197 | // @gate __DEV__ |
| 198 | it('warns if a setState is called outside of act(...)', () => { |
| 199 | let setValue = null; |
| 200 | function App() { |
| 201 | const [value, _setValue] = React.useState(0); |
| 202 | setValue = _setValue; |
| 203 | return value; |
| 204 | } |
| 205 | |
| 206 | act(() => { |
| 207 | render(<App />, container); |
| 208 | }); |
| 209 | |
| 210 | setValue(1); |
| 211 | assertConsoleErrorDev([ |
| 212 | 'An update to App inside a test was not wrapped in act(...).\n' + |
| 213 | '\n' + |
| 214 | 'When testing, code that causes React state updates should be wrapped into act(...):\n' + |
| 215 | '\n' + |
| 216 | 'act(() => {\n' + |
| 217 | ' /* fire events that update state */\n' + |
| 218 | '});\n' + |
| 219 | '/* assert on the output */\n' + |
| 220 | '\n' + |
| 221 | "This ensures that you're testing the behavior the user would see in the browser. " + |
| 222 | 'Learn more at https://react.dev/link/wrap-tests-with-act\n' + |
| 223 | ' in App (at **)', |
| 224 | ]); |
| 225 | }); |
| 226 | |
| 227 | // @gate __DEV__ |
| 228 | it('warns if a setState is called outside of act(...) after a component threw', () => { |
| 229 | let setValue = null; |
| 230 | function App({defaultValue}) { |
| 231 | if (defaultValue === undefined) { |
| 232 | throw new Error('some error'); |
| 233 | } |
| 234 | const [value, _setValue] = React.useState(defaultValue); |
| 235 | setValue = _setValue; |
| 236 | return value; |
| 237 | } |
| 238 | |
| 239 | expect(() => { |
| 240 | act(() => { |
| 241 | render(<App defaultValue={undefined} />, container); |
| 242 | }); |
| 243 | }).toThrow('some error'); |
| 244 | |
| 245 | act(() => { |
| 246 | rerender(<App defaultValue={0} />, container); |
| 247 | }); |
| 248 | |
| 249 | setValue(1); |
| 250 | assertConsoleErrorDev([ |
| 251 | 'An update to App inside a test was not wrapped in act(...).\n' + |
| 252 | '\n' + |
| 253 | 'When testing, code that causes React state updates should be wrapped into act(...):\n' + |
| 254 | '\n' + |
| 255 | 'act(() => {\n' + |
| 256 | ' /* fire events that update state */\n' + |
| 257 | '});\n' + |
| 258 | '/* assert on the output */\n' + |
| 259 | '\n' + |
| 260 | "This ensures that you're testing the behavior the user would see in the browser. " + |
| 261 | 'Learn more at https://react.dev/link/wrap-tests-with-act\n' + |
| 262 | ' in App (at **)', |
| 263 | ]); |
| 264 | }); |
| 265 | |
| 266 | // @gate __DEV__ |
| 267 | it('does not warn if IS_REACT_ACT_ENVIRONMENT is set to false', () => { |
| 268 | let setState; |
| 269 | function App() { |
| 270 | const [state, _setState] = React.useState(0); |
| 271 | setState = _setState; |
| 272 | return state; |
| 273 | } |
| 274 | |
| 275 | act(() => { |
| 276 | render(<App />, container); |
| 277 | }); |
| 278 | |
| 279 | // First show that it does warn |
| 280 | setState(1); |
| 281 | assertConsoleErrorDev([ |
| 282 | 'An update to App inside a test was not wrapped in act(...).\n' + |
| 283 | '\n' + |
| 284 | 'When testing, code that causes React state updates should be wrapped into act(...):\n' + |
| 285 | '\n' + |
| 286 | 'act(() => {\n' + |
| 287 | ' /* fire events that update state */\n' + |
| 288 | '});\n' + |
| 289 | '/* assert on the output */\n' + |
| 290 | '\n' + |
| 291 | "This ensures that you're testing the behavior the user would see in the browser. " + |
| 292 | 'Learn more at https://react.dev/link/wrap-tests-with-act\n' + |
| 293 | ' in App (at **)', |
| 294 | ]); |
| 295 | |
| 296 | // Now do the same thing again, but disable with the environment flag |
| 297 | const prevIsActEnvironment = global.IS_REACT_ACT_ENVIRONMENT; |
| 298 | global.IS_REACT_ACT_ENVIRONMENT = false; |
| 299 | try { |
| 300 | setState(2); |
| 301 | } finally { |
| 302 | global.IS_REACT_ACT_ENVIRONMENT = prevIsActEnvironment; |
| 303 | } |
| 304 | |
| 305 | // When the flag is restored to its previous value, it should start |
| 306 | // warning again. This shows that React reads the flag each time. |
| 307 | setState(3); |
| 308 | assertConsoleErrorDev([ |
| 309 | 'An update to App inside a test was not wrapped in act(...).\n' + |
| 310 | '\n' + |
| 311 | 'When testing, code that causes React state updates should be wrapped into act(...):\n' + |
| 312 | '\n' + |
| 313 | 'act(() => {\n' + |
| 314 | ' /* fire events that update state */\n' + |
| 315 | '});\n' + |
| 316 | '/* assert on the output */\n' + |
| 317 | '\n' + |
| 318 | "This ensures that you're testing the behavior the user would see in the browser. " + |
| 319 | 'Learn more at https://react.dev/link/wrap-tests-with-act\n' + |
| 320 | ' in App (at **)', |
| 321 | ]); |
| 322 | }); |
| 323 | |
| 324 | describe('fake timers', () => { |
| 325 | beforeEach(() => { |
| 326 | jest.useFakeTimers(); |
| 327 | }); |
| 328 | |
| 329 | afterEach(() => { |
| 330 | jest.useRealTimers(); |
| 331 | }); |
| 332 | |
| 333 | // @gate __DEV__ |
| 334 | it('lets a ticker update', () => { |
| 335 | function App() { |
| 336 | const [toggle, setToggle] = React.useState(0); |
| 337 | React.useEffect(() => { |
| 338 | const timeout = setTimeout(() => { |
| 339 | setToggle(1); |
| 340 | }, 200); |
| 341 | return () => clearTimeout(timeout); |
| 342 | }, []); |
| 343 | return toggle; |
| 344 | } |
| 345 | |
| 346 | act(() => { |
| 347 | render(<App />, container); |
| 348 | }); |
| 349 | act(() => { |
| 350 | jest.runAllTimers(); |
| 351 | }); |
| 352 | |
| 353 | expect(container.innerHTML).toBe('1'); |
| 354 | }); |
| 355 | |
| 356 | // @gate __DEV__ |
| 357 | it('can use the async version to catch microtasks', async () => { |
| 358 | function App() { |
| 359 | const [toggle, setToggle] = React.useState(0); |
| 360 | React.useEffect(() => { |
| 361 | // just like the previous test, except we |
| 362 | // use a promise and schedule the update |
| 363 | // after it resolves |
| 364 | sleep(200).then(() => setToggle(1)); |
| 365 | }, []); |
| 366 | return toggle; |
| 367 | } |
| 368 | |
| 369 | act(() => { |
| 370 | render(<App />, container); |
| 371 | }); |
| 372 | await act(async () => { |
| 373 | jest.runAllTimers(); |
| 374 | }); |
| 375 | |
| 376 | expect(container.innerHTML).toBe('1'); |
| 377 | }); |
| 378 | |
| 379 | // @gate __DEV__ |
| 380 | it('can handle cascading promises with fake timers', async () => { |
| 381 | // this component triggers an effect, that waits a tick, |
| 382 | // then sets state. repeats this 5 times. |
| 383 | function App() { |
| 384 | const [state, setState] = React.useState(0); |
| 385 | async function ticker() { |
| 386 | await null; |
| 387 | setState(x => x + 1); |
| 388 | } |
| 389 | React.useEffect(() => { |
| 390 | ticker(); |
| 391 | }, [Math.min(state, 4)]); |
| 392 | return state; |
| 393 | } |
| 394 | |
| 395 | await act(async () => { |
| 396 | render(<App />, container); |
| 397 | }); |
| 398 | |
| 399 | // all 5 ticks present and accounted for |
| 400 | expect(container.innerHTML).toBe('5'); |
| 401 | }); |
| 402 | |
| 403 | // @gate __DEV__ |
| 404 | it('flushes immediate re-renders with act', () => { |
| 405 | function App() { |
| 406 | const [ctr, setCtr] = React.useState(0); |
| 407 | React.useEffect(() => { |
| 408 | if (ctr === 0) { |
| 409 | setCtr(1); |
| 410 | } |
| 411 | const timeout = setTimeout(() => setCtr(2), 1000); |
| 412 | return () => clearTimeout(timeout); |
| 413 | }); |
| 414 | return ctr; |
| 415 | } |
| 416 | |
| 417 | act(() => { |
| 418 | render(<App />, container); |
| 419 | // Since effects haven't been flushed yet, this does not advance the timer |
| 420 | jest.runAllTimers(); |
| 421 | }); |
| 422 | |
| 423 | expect(container.innerHTML).toBe('1'); |
| 424 | |
| 425 | act(() => { |
| 426 | jest.runAllTimers(); |
| 427 | }); |
| 428 | |
| 429 | expect(container.innerHTML).toBe('2'); |
| 430 | }); |
| 431 | }); |
| 432 | }); |
| 433 | |
| 434 | describe('asynchronous tests', () => { |
| 435 | // @gate __DEV__ |
| 436 | it('works with timeouts', async () => { |
| 437 | function App() { |
| 438 | const [ctr, setCtr] = React.useState(0); |
| 439 | function doSomething() { |
| 440 | setTimeout(() => { |
| 441 | setCtr(1); |
| 442 | }, 50); |
| 443 | } |
| 444 | |
| 445 | React.useEffect(() => { |
| 446 | doSomething(); |
| 447 | }, []); |
| 448 | return ctr; |
| 449 | } |
| 450 | |
| 451 | await act(async () => { |
| 452 | render(<App />, container); |
| 453 | }); |
| 454 | expect(container.innerHTML).toBe('0'); |
| 455 | // Flush the pending timers |
| 456 | await act(async () => { |
| 457 | await sleep(100); |
| 458 | }); |
| 459 | expect(container.innerHTML).toBe('1'); |
| 460 | }); |
| 461 | |
| 462 | // @gate __DEV__ |
| 463 | it('flushes microtasks before exiting (async function)', async () => { |
| 464 | function App() { |
| 465 | const [ctr, setCtr] = React.useState(0); |
| 466 | async function someAsyncFunction() { |
| 467 | // queue a bunch of promises to be sure they all flush |
| 468 | await null; |
| 469 | await null; |
| 470 | await null; |
| 471 | setCtr(1); |
| 472 | } |
| 473 | React.useEffect(() => { |
| 474 | someAsyncFunction(); |
| 475 | }, []); |
| 476 | return ctr; |
| 477 | } |
| 478 | |
| 479 | await act(async () => { |
| 480 | render(<App />, container); |
| 481 | }); |
| 482 | expect(container.innerHTML).toEqual('1'); |
| 483 | }); |
| 484 | |
| 485 | // @gate __DEV__ |
| 486 | it('flushes microtasks before exiting (sync function)', async () => { |
| 487 | // Same as previous test, but the callback passed to `act` is not itself |
| 488 | // an async function. |
| 489 | function App() { |
| 490 | const [ctr, setCtr] = React.useState(0); |
| 491 | async function someAsyncFunction() { |
| 492 | // queue a bunch of promises to be sure they all flush |
| 493 | await null; |
| 494 | await null; |
| 495 | await null; |
| 496 | setCtr(1); |
| 497 | } |
| 498 | React.useEffect(() => { |
| 499 | someAsyncFunction(); |
| 500 | }, []); |
| 501 | return ctr; |
| 502 | } |
| 503 | |
| 504 | await act(() => { |
| 505 | render(<App />, container); |
| 506 | }); |
| 507 | expect(container.innerHTML).toEqual('1'); |
| 508 | }); |
| 509 | |
| 510 | // @gate __DEV__ |
| 511 | it('warns if you do not await an act call', async () => { |
| 512 | spyOnDevAndProd(console, 'error').mockImplementation(() => {}); |
| 513 | act(async () => {}); |
| 514 | // it's annoying that we have to wait a tick before this warning comes in |
| 515 | await sleep(0); |
| 516 | if (__DEV__) { |
| 517 | expect(console.error).toHaveBeenCalledTimes(1); |
| 518 | expect(console.error.mock.calls[0][0]).toMatch( |
| 519 | 'You called act(async () => ...) without await.', |
| 520 | ); |
| 521 | } |
| 522 | }); |
| 523 | |
| 524 | // @gate __DEV__ |
| 525 | it('warns if you try to interleave multiple act calls', async () => { |
| 526 | spyOnDevAndProd(console, 'error').mockImplementation(() => {}); |
| 527 | |
| 528 | await Promise.all([ |
| 529 | act(async () => { |
| 530 | await sleep(50); |
| 531 | }), |
| 532 | act(async () => { |
| 533 | await sleep(100); |
| 534 | }), |
| 535 | ]); |
| 536 | |
| 537 | await sleep(150); |
| 538 | if (__DEV__) { |
| 539 | expect(console.error).toHaveBeenCalledTimes(2); |
| 540 | expect(console.error.mock.calls[0][0]).toMatch( |
| 541 | 'You seem to have overlapping act() calls', |
| 542 | ); |
| 543 | expect(console.error.mock.calls[1][0]).toMatch( |
| 544 | 'You seem to have overlapping act() calls', |
| 545 | ); |
| 546 | } |
| 547 | }); |
| 548 | |
| 549 | // @gate __DEV__ |
| 550 | it('async commits and effects are guaranteed to be flushed', async () => { |
| 551 | function App() { |
| 552 | const [state, setState] = React.useState(0); |
| 553 | async function something() { |
| 554 | await null; |
| 555 | setState(1); |
| 556 | } |
| 557 | React.useEffect(() => { |
| 558 | something(); |
| 559 | }, []); |
| 560 | React.useEffect(() => { |
| 561 | Scheduler.log(state); |
| 562 | }); |
| 563 | return state; |
| 564 | } |
| 565 | |
| 566 | await act(async () => { |
| 567 | render(<App />, container); |
| 568 | }); |
| 569 | // exiting act() drains effects and microtasks |
| 570 | |
| 571 | assertLog([0, 1]); |
| 572 | expect(container.innerHTML).toBe('1'); |
| 573 | }); |
| 574 | |
| 575 | // @gate __DEV__ |
| 576 | it('can handle cascading promises', async () => { |
| 577 | // this component triggers an effect, that waits a tick, |
| 578 | // then sets state. repeats this 5 times. |
| 579 | function App() { |
| 580 | const [state, setState] = React.useState(0); |
| 581 | async function ticker() { |
| 582 | await null; |
| 583 | setState(x => x + 1); |
| 584 | } |
| 585 | React.useEffect(() => { |
| 586 | Scheduler.log(state); |
| 587 | ticker(); |
| 588 | }, [Math.min(state, 4)]); |
| 589 | return state; |
| 590 | } |
| 591 | |
| 592 | await act(async () => { |
| 593 | render(<App />, container); |
| 594 | }); |
| 595 | // all 5 ticks present and accounted for |
| 596 | assertLog([0, 1, 2, 3, 4]); |
| 597 | expect(container.innerHTML).toBe('5'); |
| 598 | }); |
| 599 | }); |
| 600 | |
| 601 | describe('error propagation', () => { |
| 602 | // @gate __DEV__ |
| 603 | it('propagates errors - sync', () => { |
| 604 | let err; |
| 605 | try { |
| 606 | act(() => { |
| 607 | throw new Error('some error'); |
| 608 | }); |
| 609 | } catch (_err) { |
| 610 | err = _err; |
| 611 | } finally { |
| 612 | expect(err instanceof Error).toBe(true); |
| 613 | expect(err.message).toBe('some error'); |
| 614 | } |
| 615 | }); |
| 616 | |
| 617 | // @gate __DEV__ |
| 618 | it('should propagate errors from effects - sync', () => { |
| 619 | function App() { |
| 620 | React.useEffect(() => { |
| 621 | throw new Error('oh no'); |
| 622 | }); |
| 623 | return null; |
| 624 | } |
| 625 | let error; |
| 626 | |
| 627 | try { |
| 628 | act(() => { |
| 629 | render(<App />, container); |
| 630 | }); |
| 631 | } catch (_error) { |
| 632 | error = _error; |
| 633 | } finally { |
| 634 | expect(error instanceof Error).toBe(true); |
| 635 | expect(error.message).toBe('oh no'); |
| 636 | } |
| 637 | }); |
| 638 | |
| 639 | // @gate __DEV__ |
| 640 | it('propagates errors - async', async () => { |
| 641 | let err; |
| 642 | try { |
| 643 | await act(async () => { |
| 644 | await sleep(100); |
| 645 | throw new Error('some error'); |
| 646 | }); |
| 647 | } catch (_err) { |
| 648 | err = _err; |
| 649 | } finally { |
| 650 | expect(err instanceof Error).toBe(true); |
| 651 | expect(err.message).toBe('some error'); |
| 652 | } |
| 653 | }); |
| 654 | |
| 655 | // @gate __DEV__ |
| 656 | it('should cleanup after errors - sync', () => { |
| 657 | function App() { |
| 658 | React.useEffect(() => { |
| 659 | Scheduler.log('oh yes'); |
| 660 | }); |
| 661 | return null; |
| 662 | } |
| 663 | let error; |
| 664 | try { |
| 665 | act(() => { |
| 666 | throw new Error('oh no'); |
| 667 | }); |
| 668 | } catch (_error) { |
| 669 | error = _error; |
| 670 | } finally { |
| 671 | expect(error instanceof Error).toBe(true); |
| 672 | expect(error.message).toBe('oh no'); |
| 673 | // should be able to render components after this tho |
| 674 | act(() => { |
| 675 | render(<App />, container); |
| 676 | }); |
| 677 | assertLog(['oh yes']); |
| 678 | } |
| 679 | }); |
| 680 | |
| 681 | // @gate __DEV__ |
| 682 | it('should cleanup after errors - async', async () => { |
| 683 | function App() { |
| 684 | async function somethingAsync() { |
| 685 | await null; |
| 686 | Scheduler.log('oh yes'); |
| 687 | } |
| 688 | React.useEffect(() => { |
| 689 | somethingAsync(); |
| 690 | }); |
| 691 | return null; |
| 692 | } |
| 693 | let error; |
| 694 | try { |
| 695 | await act(async () => { |
| 696 | await sleep(100); |
| 697 | throw new Error('oh no'); |
| 698 | }); |
| 699 | } catch (_error) { |
| 700 | error = _error; |
| 701 | } finally { |
| 702 | expect(error instanceof Error).toBe(true); |
| 703 | expect(error.message).toBe('oh no'); |
| 704 | // should be able to render components after this tho |
| 705 | await act(async () => { |
| 706 | render(<App />, container); |
| 707 | }); |
| 708 | assertLog(['oh yes']); |
| 709 | } |
| 710 | }); |
| 711 | }); |
| 712 | |
| 713 | describe('suspense', () => { |
| 714 | if (__DEV__ && __EXPERIMENTAL__) { |
| 715 | // todo - remove __DEV__ check once we start using testing builds |
| 716 | |
| 717 | // @gate __DEV__ |
| 718 | it('triggers fallbacks if available', async () => { |
| 719 | let resolved = false; |
| 720 | let resolve; |
| 721 | const promise = new Promise(_resolve => { |
| 722 | resolve = _resolve; |
| 723 | }); |
| 724 | |
| 725 | function Suspends() { |
| 726 | if (resolved) { |
| 727 | return 'was suspended'; |
| 728 | } |
| 729 | throw promise; |
| 730 | } |
| 731 | |
| 732 | function App(props) { |
| 733 | return ( |
| 734 | <React.Suspense |
| 735 | fallback={<span data-test-id="spinner">loading...</span>}> |
| 736 | {props.suspend ? <Suspends /> : 'content'} |
| 737 | </React.Suspense> |
| 738 | ); |
| 739 | } |
| 740 | |
| 741 | // render something so there's content |
| 742 | act(() => { |
| 743 | render(<App suspend={false} />, container); |
| 744 | }); |
| 745 | |
| 746 | // trigger a suspendy update |
| 747 | act(() => { |
| 748 | rerender(<App suspend={true} />); |
| 749 | }); |
| 750 | expect( |
| 751 | document.querySelector('[data-test-id=spinner]'), |
| 752 | ).not.toBeNull(); |
| 753 | |
| 754 | // now render regular content again |
| 755 | act(() => { |
| 756 | rerender(<App suspend={false} />); |
| 757 | }); |
| 758 | expect(document.querySelector('[data-test-id=spinner]')).toBeNull(); |
| 759 | |
| 760 | // trigger a suspendy update with a delay |
| 761 | React.startTransition(() => { |
| 762 | act(() => { |
| 763 | rerender(<App suspend={true} />); |
| 764 | }); |
| 765 | }); |
| 766 | |
| 767 | // In Concurrent Mode, refresh transitions delay indefinitely. |
| 768 | expect(document.querySelector('[data-test-id=spinner]')).toBeNull(); |
| 769 | |
| 770 | // resolve the promise |
| 771 | await act(async () => { |
| 772 | resolved = true; |
| 773 | resolve(); |
| 774 | }); |
| 775 | |
| 776 | // spinner gone, content showing |
| 777 | expect(document.querySelector('[data-test-id=spinner]')).toBeNull(); |
| 778 | expect(container.textContent).toBe('was suspended'); |
| 779 | }); |
| 780 | } |
| 781 | }); |
| 782 | }); |
| 783 | } |