|
7 | 7 | from reactpy import html
|
8 | 8 | from reactpy.config import REACTPY_DEBUG_MODE
|
9 | 9 | from reactpy.core._life_cycle_hook import LifeCycleHook
|
10 |
| -from reactpy.core.hooks import strictly_equal |
| 10 | +from reactpy.core.hooks import Effect, strictly_equal |
11 | 11 | from reactpy.core.layout import Layout
|
12 | 12 | from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll
|
13 | 13 | from reactpy.testing.logs import assert_reactpy_did_not_log
|
@@ -276,18 +276,18 @@ def double_set_state(event):
|
276 | 276 | first = await display.page.wait_for_selector("#first")
|
277 | 277 | second = await display.page.wait_for_selector("#second")
|
278 | 278 |
|
279 |
| - await poll(first.get_attribute("data-value")).until_equals("0") |
280 |
| - await poll(second.get_attribute("data-value")).until_equals("0") |
| 279 | + await poll(first.get_attribute, "data-value").until_equals("0") |
| 280 | + await poll(second.get_attribute, "data-value").until_equals("0") |
281 | 281 |
|
282 | 282 | await button.click()
|
283 | 283 |
|
284 |
| - await poll(first.get_attribute("data-value")).until_equals("1") |
285 |
| - await poll(second.get_attribute("data-value")).until_equals("1") |
| 284 | + await poll(first.get_attribute, "data-value").until_equals("1") |
| 285 | + await poll(second.get_attribute, "data-value").until_equals("1") |
286 | 286 |
|
287 | 287 | await button.click()
|
288 | 288 |
|
289 |
| - await poll(first.get_attribute("data-value")).until_equals("2") |
290 |
| - await poll(second.get_attribute("data-value")).until_equals("2") |
| 289 | + await poll(first.get_attribute, "data-value").until_equals("2") |
| 290 | + await poll(second.get_attribute, "data-value").until_equals("2") |
291 | 291 |
|
292 | 292 |
|
293 | 293 | async def test_use_effect_callback_occurs_after_full_render_is_complete():
|
@@ -531,13 +531,84 @@ async def effect(e):
|
531 | 531 | async with reactpy.Layout(ComponentWithAsyncEffect()) as layout:
|
532 | 532 | await layout.render()
|
533 | 533 |
|
| 534 | + await effect_ran.wait() |
| 535 | + |
534 | 536 | component_hook.latest.schedule_render()
|
535 | 537 |
|
536 | 538 | await layout.render()
|
537 | 539 |
|
538 | 540 | await asyncio.wait_for(cleanup_ran.wait(), 1)
|
539 | 541 |
|
540 | 542 |
|
| 543 | +async def test_effect_with_early_stop_cancels_immediately(): |
| 544 | + never_happens = asyncio.Event() |
| 545 | + did_start = WaitForEvent() |
| 546 | + did_cleanup = WaitForEvent() |
| 547 | + did_cancel = WaitForEvent() |
| 548 | + |
| 549 | + async def effect_func(e): |
| 550 | + async with e: |
| 551 | + did_start.set() |
| 552 | + try: |
| 553 | + await never_happens.wait() |
| 554 | + except asyncio.CancelledError: |
| 555 | + did_cancel.set() |
| 556 | + raise |
| 557 | + did_cleanup.set() |
| 558 | + |
| 559 | + effect = Effect(effect_func) |
| 560 | + effect.stop_no_wait() |
| 561 | + await did_start.wait() |
| 562 | + await did_cancel.wait() |
| 563 | + await did_cleanup.wait() |
| 564 | + |
| 565 | + |
| 566 | +async def test_long_effect_is_cancelled(): |
| 567 | + never_happens = asyncio.Event() |
| 568 | + did_start = WaitForEvent() |
| 569 | + did_cleanup = WaitForEvent() |
| 570 | + did_cancel = WaitForEvent() |
| 571 | + |
| 572 | + async def effect_func(e): |
| 573 | + async with e: |
| 574 | + did_start.set() |
| 575 | + try: |
| 576 | + await never_happens.wait() |
| 577 | + except asyncio.CancelledError: |
| 578 | + did_cancel.set() |
| 579 | + raise |
| 580 | + did_cleanup.set() |
| 581 | + |
| 582 | + effect = Effect(effect_func) |
| 583 | + |
| 584 | + await did_start.wait() |
| 585 | + await effect.stop() |
| 586 | + await did_cancel.wait() |
| 587 | + await did_cleanup.wait() |
| 588 | + |
| 589 | + |
| 590 | +async def test_effect_external_cancellation_is_propagated(): |
| 591 | + did_start = WaitForEvent() |
| 592 | + did_cleanup = Ref(False) |
| 593 | + |
| 594 | + async def effect_func(e): |
| 595 | + async with e: |
| 596 | + did_start.set() |
| 597 | + asyncio.current_task().cancel() |
| 598 | + await asyncio.sleep(0) # allow cancellation to propagate |
| 599 | + did_cleanup.current = True |
| 600 | + |
| 601 | + async def main(): |
| 602 | + effect = Effect(effect_func) |
| 603 | + await did_start.wait() |
| 604 | + await effect.stop() |
| 605 | + |
| 606 | + with pytest.raises(asyncio.CancelledError): |
| 607 | + await main() |
| 608 | + |
| 609 | + assert not did_cleanup.current |
| 610 | + |
| 611 | + |
541 | 612 | @pytest.mark.skipif(
|
542 | 613 | sys.version_info < (3, 11),
|
543 | 614 | reason="asyncio.Task.uncancel does not exist",
|
@@ -571,6 +642,64 @@ async def effect(e):
|
571 | 642 | await asyncio.wait_for(cleanup_ran.wait(), 1)
|
572 | 643 |
|
573 | 644 |
|
| 645 | +async def test_use_async_effect_error_in_effect_is_propagated_and_handled_gracefully(): |
| 646 | + component_hook = HookCatcher() |
| 647 | + effect_ran = WaitForEvent() |
| 648 | + |
| 649 | + @reactpy.component |
| 650 | + @component_hook.capture |
| 651 | + def ComponentWithAsyncEffect(): |
| 652 | + @reactpy.use_effect(dependencies=None) # force this to run every time |
| 653 | + async def effect(e): |
| 654 | + async with e: |
| 655 | + effect_ran.set() |
| 656 | + raise ValueError("Something went wrong") |
| 657 | + |
| 658 | + return reactpy.html.div() |
| 659 | + |
| 660 | + with assert_reactpy_did_log( |
| 661 | + match_message=r"Error while stopping effect", |
| 662 | + error_type=ValueError, |
| 663 | + ): |
| 664 | + async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: |
| 665 | + await layout.render() |
| 666 | + |
| 667 | + component_hook.latest.schedule_render() |
| 668 | + |
| 669 | + await layout.render() |
| 670 | + |
| 671 | + |
| 672 | +async def test_use_async_effect_error_after_stop_is_handled_gracefully(): |
| 673 | + component_hook = HookCatcher() |
| 674 | + effect_ran = WaitForEvent() |
| 675 | + cleanup_ran = WaitForEvent() |
| 676 | + |
| 677 | + @reactpy.component |
| 678 | + @component_hook.capture |
| 679 | + def ComponentWithAsyncEffect(): |
| 680 | + @reactpy.use_effect(dependencies=None) # force this to run every time |
| 681 | + async def effect(e): |
| 682 | + async with e: |
| 683 | + effect_ran.set() |
| 684 | + cleanup_ran.set() |
| 685 | + raise ValueError("Something went wrong") |
| 686 | + |
| 687 | + return reactpy.html.div() |
| 688 | + |
| 689 | + with assert_reactpy_did_log( |
| 690 | + match_message=r"Error while stopping effect", |
| 691 | + error_type=ValueError, |
| 692 | + ): |
| 693 | + async with reactpy.Layout(ComponentWithAsyncEffect()) as layout: |
| 694 | + await layout.render() |
| 695 | + |
| 696 | + component_hook.latest.schedule_render() |
| 697 | + |
| 698 | + await layout.render() |
| 699 | + |
| 700 | + await asyncio.wait_for(cleanup_ran.wait(), 1) |
| 701 | + |
| 702 | + |
574 | 703 | async def test_use_async_effect_cleanup_task():
|
575 | 704 | component_hook = HookCatcher()
|
576 | 705 | effect_ran = WaitForEvent()
|
|
0 commit comments