Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better Async Effects #1093

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/.hatch-run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ on:
python-version-array:
required: false
type: string
default: '["3.x"]'
default: '["3.11"]'
node-registry-url:
required: false
type: string
Expand Down
8 changes: 8 additions & 0 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ Unreleased
- :pull:`1118` - `module_from_template` is broken with a recent release of `requests`
- :pull:`1131` - `module_from_template` did not work when using Flask backend

**Added**

- :pull:`1093` - Better async effects (see :ref:`Async Effects`)
- :pull:`1093` - Support concurrent renders - multiple components are now able to render
simultaneously. This is a significant change to the underlying rendering logic and
should be considered experimental. You can enable this feature by setting
``REACTPY_FEATURE_CONCURRENT_RENDER=1`` when running ReactPy.


v1.0.2
------
Expand Down
1 change: 1 addition & 0 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@
"sanic": ("https://sanic.readthedocs.io/en/latest/", None),
"tornado": ("https://www.tornadoweb.org/en/stable/", None),
"flask": ("https://flask.palletsprojects.com/en/1.1.x/", None),
"anyio": ("https://anyio.readthedocs.io/en/stable", None),
}

# -- Options for todo extension ----------------------------------------------
Expand Down
71 changes: 54 additions & 17 deletions docs/source/reference/hooks-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ Use Effect

.. code-block::

use_effect(did_render)
@use_effect
def did_render():
... # imperative or state mutating logic

The ``use_effect`` hook accepts a function which may be imperative, or mutate state. The
function will be called immediately after the layout has fully updated.
Expand Down Expand Up @@ -117,12 +119,11 @@ then closing a connection:

.. code-block::

@use_effect
def establish_connection():
connection = open_connection()
return lambda: close_connection(connection)

use_effect(establish_connection)

The clean-up function will be run before the component is unmounted or, before the next
effect is triggered when the component re-renders. You can
:ref:`conditionally fire events <Conditional Effects>` to avoid triggering them each
Expand All @@ -141,40 +142,76 @@ example, imagine that we had an effect that connected to a ``url`` state variabl

url, set_url = use_state("https://example.com")

@use_effect
def establish_connection():
connection = open_connection(url)
return lambda: close_connection(connection)

use_effect(establish_connection)

Here, a new connection will be established whenever a new ``url`` is set.

.. warning::

A component will be unable to render until all its outstanding effects have been
cleaned up. As such, it's best to keep cleanup logic as simple as possible and/or
to impose a time limit.


Async Effects
.............

A behavior unique to ReactPy's implementation of ``use_effect`` is that it natively
supports ``async`` functions:
supports ``async`` effects. Async effect functions may either be an async function
or an async generator. If your effect doesn't need to do any cleanup, then you can
simply write an async function.

.. code-block::

async def non_blocking_effect():
resource = await do_something_asynchronously()
return lambda: blocking_close(resource)
@use_effect
async def my_async_effect():
await do_something()

use_effect(non_blocking_effect)
However, if you need to do any cleanup, then you'll need to write an async generator
instead. The generator should run the effect logic in a ``try`` block, ``yield`` control
back to ReactPy, and then run the cleanup logic in a ``finally`` block:

.. code-block::

There are **three important subtleties** to note about using asynchronous effects:
@use_effect
async def my_async_effect():
try:
await effect_logic()
yield
finally:
await cleanup_logic()

1. The cleanup function must be a normal synchronous function.
When a component is re-rendered or unmounted the effect will be cancelled if it is still
running. This will typically happen for long-lived effects. One example might be an
effect that opens a connection and then responds to messages for the lifetime of the
connection:

2. Asynchronous effects which do not complete before the next effect is created
following a re-render will be cancelled. This means an
:class:`~asyncio.CancelledError` will be raised somewhere in the body of the effect.
.. code-block::

@use_effect
async def my_async_effect():
conn = await open_connection()
try:
while True:
msg = await conn.recv()
await handle_message(msg)
finally:
await close_connection(conn)

.. warning::

Because an effect can be cancelled at any time, it's possible that the cleanup logic
will run before all of the effect logic has finished. For example, in the code
above, we exclude ``conn = await open_connection()`` from the ``try`` block because
if the effect is cancelled before the connection is opened, then we don't need to
close it.

.. note::

3. An asynchronous effect may occur any time after the update which added this effect
and before the next effect following a subsequent update.
We don't need a yield statement here because the effect only ends when it's cancelled.


Manual Effect Conditions
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ dependencies = [
"semver >=2, <3",
"twine",
"pre-commit",
# required by some packages during install
"setuptools",
]

[tool.hatch.envs.default.scripts]
Expand Down Expand Up @@ -130,8 +132,9 @@ ignore = [
"PLR0915",
]
unfixable = [
# Don't touch unused imports
# Don't touch unused imports or unused variables
"F401",
"F841",
]

[tool.ruff.isort]
Expand Down
6 changes: 4 additions & 2 deletions src/py/reactpy/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ dependencies = [
"colorlog >=6",
"asgiref >=3",
"lxml >=4",
# required by some packages during install
"setuptools",
]
[project.optional-dependencies]
all = ["reactpy[starlette,sanic,fastapi,flask,tornado,testing]"]
Expand Down Expand Up @@ -92,8 +94,8 @@ dependencies = [
"jsonpointer",
]
[tool.hatch.envs.default.scripts]
test = "playwright install && pytest {args:tests}"
test-cov = "playwright install && coverage run -m pytest {args:tests}"
test = "playwright install chromium && pytest {args:tests}"
test-cov = "playwright install chromium && coverage run -m pytest {args:tests}"
cov-report = [
# "- coverage combine",
"coverage report",
Expand Down
3 changes: 2 additions & 1 deletion src/py/reactpy/reactpy/backend/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typing import Any

from reactpy.backend.types import Connection, Location
from reactpy.core.hooks import Context, create_context, use_context
from reactpy.core.hooks import create_context, use_context
from reactpy.core.types import Context

# backend implementations should establish this context at the root of an app
ConnectionContext: Context[Connection[Any] | None] = create_context(None)
Expand Down
16 changes: 16 additions & 0 deletions src/py/reactpy/reactpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,19 @@ def boolean(value: str | bool | int) -> bool:
validator=float,
)
"""A default timeout for testing utilities in ReactPy"""

REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT = Option(
"REACTPY_EFFECT_DEFAULT_STOP_TIMEOUT",
30.0,
mutable=False,
validator=float,
)
"""The default amount of time to wait for an effect to complete"""

REACTPY_CONCURRENT_RENDERING = Option(
"REACTPY_CONCURRENT_RENDERING",
default=False,
mutable=True,
validator=boolean,
)
"""Whether to render components concurrently. This is currently an experimental feature."""
Loading
Loading