Skip to content

Commit 3a3ad3f

Browse files
authoredDec 28, 2023
Skip rendering None in all situations (#1171)
* skip rendering none * add changelog * conditional render none should not reset state for sibling components * minor renaming + better changelog * misc fixes * raises exceptiongroup * skipif * handle egroup in starlette * final nocov
1 parent 43009e4 commit 3a3ad3f

File tree

9 files changed

+116
-48
lines changed

9 files changed

+116
-48
lines changed
 

‎docs/source/about/changelog.rst

+17
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ Unreleased
3535
the overall responsiveness of your app, particularly when handling larger renders
3636
that would otherwise block faster renders from being processed.
3737

38+
**Changed**
39+
40+
- :pull:`1171` - Previously ``None``, when present in an HTML element, would render as
41+
the string ``"None"``. Now ``None`` will not render at all. This is consistent with
42+
how ``None`` is handled when returned from components. It also makes it easier to
43+
conditionally render elements. For example, previously you would have needed to use a
44+
fragment to conditionally render an element by writing
45+
``something if condition else html._()``. Now you can simply write
46+
``something if condition else None``.
47+
48+
**Deprecated**
49+
50+
- :pull:`1171` - The ``Stop`` exception. Recent releases of ``anyio`` have made this
51+
exception difficult to use since it now raises an ``ExceptionGroup``. This exception
52+
was primarily used for internal testing purposes and so is now deprecated.
53+
54+
3855
v1.0.2
3956
------
4057

‎src/py/reactpy/pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ classifiers = [
2525
"Programming Language :: Python :: Implementation :: PyPy",
2626
]
2727
dependencies = [
28+
"exceptiongroup >=1.0",
2829
"typing-extensions >=3.10",
2930
"mypy-extensions >=0.4.3",
3031
"anyio >=3",

‎src/py/reactpy/reactpy/__init__.py

-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
use_state,
1717
)
1818
from reactpy.core.layout import Layout
19-
from reactpy.core.serve import Stop
2019
from reactpy.core.vdom import vdom
2120
from reactpy.utils import Ref, html_to_vdom, vdom_to_html
2221

‎src/py/reactpy/reactpy/backend/starlette.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from dataclasses import dataclass
88
from typing import Any, Callable
99

10+
from exceptiongroup import BaseExceptionGroup
1011
from starlette.applications import Starlette
1112
from starlette.middleware.cors import CORSMiddleware
1213
from starlette.requests import Request
@@ -137,8 +138,6 @@ async def serve_index(request: Request) -> HTMLResponse:
137138
def _setup_single_view_dispatcher_route(
138139
options: Options, app: Starlette, component: RootComponentConstructor
139140
) -> None:
140-
@app.websocket_route(str(STREAM_PATH))
141-
@app.websocket_route(f"{STREAM_PATH}/{{path:path}}")
142141
async def model_stream(socket: WebSocket) -> None:
143142
await socket.accept()
144143
send, recv = _make_send_recv_callbacks(socket)
@@ -162,8 +161,16 @@ async def model_stream(socket: WebSocket) -> None:
162161
send,
163162
recv,
164163
)
165-
except WebSocketDisconnect as error:
166-
logger.info(f"WebSocket disconnect: {error.code}")
164+
except BaseExceptionGroup as egroup:
165+
for e in egroup.exceptions:
166+
if isinstance(e, WebSocketDisconnect):
167+
logger.info(f"WebSocket disconnect: {e.code}")
168+
break
169+
else: # nocov
170+
raise
171+
172+
app.add_websocket_route(str(STREAM_PATH), model_stream)
173+
app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream)
167174

168175

169176
def _make_send_recv_callbacks(

‎src/py/reactpy/reactpy/core/layout.py

+27-21
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
wait,
1212
)
1313
from collections import Counter
14-
from collections.abc import Iterator
14+
from collections.abc import Sequence
1515
from contextlib import AsyncExitStack
1616
from logging import getLogger
1717
from typing import (
@@ -27,6 +27,7 @@
2727
from weakref import ref as weakref
2828

2929
from anyio import Semaphore
30+
from typing_extensions import TypeAlias
3031

3132
from reactpy.config import (
3233
REACTPY_ASYNC_RENDERING,
@@ -37,8 +38,10 @@
3738
from reactpy.core.types import (
3839
ComponentType,
3940
EventHandlerDict,
41+
Key,
4042
LayoutEventMessage,
4143
LayoutUpdateMessage,
44+
VdomChild,
4245
VdomDict,
4346
VdomJson,
4447
)
@@ -189,9 +192,7 @@ async def _render_component(
189192
# wrap the model in a fragment (i.e. tagName="") to ensure components have
190193
# a separate node in the model state tree. This could be removed if this
191194
# components are given a node in the tree some other way
192-
wrapper_model: VdomDict = {"tagName": ""}
193-
if raw_model is not None:
194-
wrapper_model["children"] = [raw_model]
195+
wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]}
195196
await self._render_model(exit_stack, old_state, new_state, wrapper_model)
196197
except Exception as error:
197198
logger.exception(f"Failed to render {component}")
@@ -329,11 +330,11 @@ async def _render_model_children(
329330
await self._unmount_model_states(list(old_state.children_by_key.values()))
330331
return None
331332

332-
child_type_key_tuples = list(_process_child_type_and_key(raw_children))
333+
children_info = _get_children_info(raw_children)
333334

334-
new_keys = {item[2] for item in child_type_key_tuples}
335-
if len(new_keys) != len(raw_children):
336-
key_counter = Counter(item[2] for item in child_type_key_tuples)
335+
new_keys = {k for _, _, k in children_info}
336+
if len(new_keys) != len(children_info):
337+
key_counter = Counter(item[2] for item in children_info)
337338
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
338339
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
339340
raise ValueError(msg)
@@ -345,7 +346,7 @@ async def _render_model_children(
345346
)
346347

347348
new_state.model.current["children"] = []
348-
for index, (child, child_type, key) in enumerate(child_type_key_tuples):
349+
for index, (child, child_type, key) in enumerate(children_info):
349350
old_child_state = old_state.children_by_key.get(key)
350351
if child_type is _DICT_TYPE:
351352
old_child_state = old_state.children_by_key.get(key)
@@ -420,17 +421,17 @@ async def _render_model_children_without_old_state(
420421
new_state: _ModelState,
421422
raw_children: list[Any],
422423
) -> None:
423-
child_type_key_tuples = list(_process_child_type_and_key(raw_children))
424+
children_info = _get_children_info(raw_children)
424425

425-
new_keys = {item[2] for item in child_type_key_tuples}
426-
if len(new_keys) != len(raw_children):
427-
key_counter = Counter(item[2] for item in child_type_key_tuples)
426+
new_keys = {k for _, _, k in children_info}
427+
if len(new_keys) != len(children_info):
428+
key_counter = Counter(k for _, _, k in children_info)
428429
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
429430
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
430431
raise ValueError(msg)
431432

432433
new_state.model.current["children"] = []
433-
for index, (child, child_type, key) in enumerate(child_type_key_tuples):
434+
for index, (child, child_type, key) in enumerate(children_info):
434435
if child_type is _DICT_TYPE:
435436
child_state = _make_element_model_state(new_state, index, key)
436437
await self._render_model(exit_stack, None, child_state, child)
@@ -609,7 +610,7 @@ def __init__(
609610
key: Any,
610611
model: Ref[VdomJson],
611612
patch_path: str,
612-
children_by_key: dict[str, _ModelState],
613+
children_by_key: dict[Key, _ModelState],
613614
targets_by_event: dict[str, str],
614615
life_cycle_state: _LifeCycleState | None = None,
615616
):
@@ -720,16 +721,17 @@ async def get(self) -> _Type:
720721
return value
721722

722723

723-
def _process_child_type_and_key(
724-
children: list[Any],
725-
) -> Iterator[tuple[Any, _ElementType, Any]]:
724+
def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]:
725+
infos: list[_ChildInfo] = []
726726
for index, child in enumerate(children):
727-
if isinstance(child, dict):
727+
if child is None:
728+
continue
729+
elif isinstance(child, dict):
728730
child_type = _DICT_TYPE
729731
key = child.get("key")
730732
elif isinstance(child, ComponentType):
731733
child_type = _COMPONENT_TYPE
732-
key = getattr(child, "key", None)
734+
key = child.key
733735
else:
734736
child = f"{child}"
735737
child_type = _STRING_TYPE
@@ -738,8 +740,12 @@ def _process_child_type_and_key(
738740
if key is None:
739741
key = index
740742

741-
yield (child, child_type, key)
743+
infos.append((child, child_type, key))
742744

745+
return infos
746+
747+
748+
_ChildInfo: TypeAlias = tuple[Any, "_ElementType", Key]
743749

744750
# used in _process_child_type_and_key
745751
_ElementType = NewType("_ElementType", int)

‎src/py/reactpy/reactpy/core/serve.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Awaitable
44
from logging import getLogger
55
from typing import Callable
6+
from warnings import warn
67

78
from anyio import create_task_group
89
from anyio.abc import TaskGroup
@@ -24,7 +25,9 @@
2425

2526

2627
class Stop(BaseException):
27-
"""Stop serving changes and events
28+
"""Deprecated
29+
30+
Stop serving changes and events
2831
2932
Raising this error will tell dispatchers to gracefully exit. Typically this is
3033
called by code running inside a layout to tell it to stop rendering.
@@ -42,7 +45,12 @@ async def serve_layout(
4245
async with create_task_group() as task_group:
4346
task_group.start_soon(_single_outgoing_loop, layout, send)
4447
task_group.start_soon(_single_incoming_loop, task_group, layout, recv)
45-
except Stop:
48+
except Stop: # nocov
49+
warn(
50+
"The Stop exception is deprecated and will be removed in a future version",
51+
UserWarning,
52+
stacklevel=1,
53+
)
4654
logger.info(f"Stopped serving {layout}")
4755

4856

‎src/py/reactpy/reactpy/core/types.py

+2-9
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async def __aexit__(
9191
VdomAttributes = Mapping[str, Any]
9292
"""Describes the attributes of a :class:`VdomDict`"""
9393

94-
VdomChild: TypeAlias = "ComponentType | VdomDict | str"
94+
VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any"
9595
"""A single child element of a :class:`VdomDict`"""
9696

9797
VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild"
@@ -100,14 +100,7 @@ async def __aexit__(
100100

101101
class _VdomDictOptional(TypedDict, total=False):
102102
key: Key | None
103-
children: Sequence[
104-
# recursive types are not allowed yet:
105-
# https://github.com/python/mypy/issues/731
106-
ComponentType
107-
| dict[str, Any]
108-
| str
109-
| Any
110-
]
103+
children: Sequence[ComponentType | VdomChild]
111104
attributes: VdomAttributes
112105
eventHandlers: EventHandlerDict
113106
importSource: ImportSourceDict

‎src/py/reactpy/tests/test_core/test_layout.py

+42-9
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,6 @@ def SimpleComponent():
102102
)
103103

104104

105-
async def test_component_can_return_none():
106-
@reactpy.component
107-
def SomeComponent():
108-
return None
109-
110-
async with reactpy.Layout(SomeComponent()) as layout:
111-
assert (await layout.render())["model"] == {"tagName": ""}
112-
113-
114105
async def test_nested_component_layout():
115106
parent_set_state = reactpy.Ref(None)
116107
child_set_state = reactpy.Ref(None)
@@ -1310,3 +1301,45 @@ def child_2():
13101301

13111302
assert child_1_render_count.current == 1
13121303
assert child_2_render_count.current == 1
1304+
1305+
1306+
async def test_none_does_not_render():
1307+
@component
1308+
def Root():
1309+
return html.div(None, Child())
1310+
1311+
@component
1312+
def Child():
1313+
return None
1314+
1315+
async with layout_runner(Layout(Root())) as runner:
1316+
tree = await runner.render()
1317+
assert tree == {
1318+
"tagName": "",
1319+
"children": [
1320+
{"tagName": "div", "children": [{"tagName": "", "children": []}]}
1321+
],
1322+
}
1323+
1324+
1325+
async def test_conditionally_render_none_does_not_trigger_state_change_in_siblings():
1326+
toggle_condition = Ref()
1327+
effect_run_count = Ref(0)
1328+
1329+
@component
1330+
def Root():
1331+
condition, toggle_condition.current = use_toggle(True)
1332+
return html.div("text" if condition else None, Child())
1333+
1334+
@component
1335+
def Child():
1336+
@reactpy.use_effect
1337+
def effect():
1338+
effect_run_count.current += 1
1339+
1340+
async with layout_runner(Layout(Root())) as runner:
1341+
await runner.render()
1342+
poll(lambda: effect_run_count.current).until_equals(1)
1343+
toggle_condition.current()
1344+
await runner.render()
1345+
assert effect_run_count.current == 1

‎src/py/reactpy/tests/test_core/test_serve.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import asyncio
2+
import sys
23
from collections.abc import Sequence
34
from typing import Any
45

6+
import pytest
57
from jsonpointer import set_pointer
68

79
import reactpy
@@ -31,7 +33,7 @@ async def send(patch):
3133
changes.append(patch)
3234
sem.release()
3335
if not events_to_inject:
34-
raise reactpy.Stop()
36+
raise Exception("Stop running")
3537

3638
async def recv():
3739
await sem.acquire()
@@ -90,10 +92,12 @@ def Counter():
9092
return reactpy.html.div({EVENT_NAME: handler, "count": count})
9193

9294

95+
@pytest.mark.skipif(sys.version_info < (3, 11), reason="ExceptionGroup not available")
9396
async def test_dispatch():
9497
events, expected_model = make_events_and_expected_model()
9598
changes, send, recv = make_send_recv_callbacks(events)
96-
await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1)
99+
with pytest.raises(ExceptionGroup):
100+
await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1)
97101
assert_changes_produce_expected_model(changes, expected_model)
98102

99103

0 commit comments

Comments
 (0)
Please sign in to comment.