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
This commit is contained in:
parent
43009e42fe
commit
3a3ad3f706
9 changed files with 116 additions and 48 deletions
|
@ -35,6 +35,23 @@ Unreleased
|
|||
the overall responsiveness of your app, particularly when handling larger renders
|
||||
that would otherwise block faster renders from being processed.
|
||||
|
||||
**Changed**
|
||||
|
||||
- :pull:`1171` - Previously ``None``, when present in an HTML element, would render as
|
||||
the string ``"None"``. Now ``None`` will not render at all. This is consistent with
|
||||
how ``None`` is handled when returned from components. It also makes it easier to
|
||||
conditionally render elements. For example, previously you would have needed to use a
|
||||
fragment to conditionally render an element by writing
|
||||
``something if condition else html._()``. Now you can simply write
|
||||
``something if condition else None``.
|
||||
|
||||
**Deprecated**
|
||||
|
||||
- :pull:`1171` - The ``Stop`` exception. Recent releases of ``anyio`` have made this
|
||||
exception difficult to use since it now raises an ``ExceptionGroup``. This exception
|
||||
was primarily used for internal testing purposes and so is now deprecated.
|
||||
|
||||
|
||||
v1.0.2
|
||||
------
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ classifiers = [
|
|||
"Programming Language :: Python :: Implementation :: PyPy",
|
||||
]
|
||||
dependencies = [
|
||||
"exceptiongroup >=1.0",
|
||||
"typing-extensions >=3.10",
|
||||
"mypy-extensions >=0.4.3",
|
||||
"anyio >=3",
|
||||
|
|
|
@ -16,7 +16,6 @@ from reactpy.core.hooks import (
|
|||
use_state,
|
||||
)
|
||||
from reactpy.core.layout import Layout
|
||||
from reactpy.core.serve import Stop
|
||||
from reactpy.core.vdom import vdom
|
||||
from reactpy.utils import Ref, html_to_vdom, vdom_to_html
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ from collections.abc import Awaitable
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
|
@ -137,8 +138,6 @@ def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLRes
|
|||
def _setup_single_view_dispatcher_route(
|
||||
options: Options, app: Starlette, component: RootComponentConstructor
|
||||
) -> None:
|
||||
@app.websocket_route(str(STREAM_PATH))
|
||||
@app.websocket_route(f"{STREAM_PATH}/{{path:path}}")
|
||||
async def model_stream(socket: WebSocket) -> None:
|
||||
await socket.accept()
|
||||
send, recv = _make_send_recv_callbacks(socket)
|
||||
|
@ -162,8 +161,16 @@ def _setup_single_view_dispatcher_route(
|
|||
send,
|
||||
recv,
|
||||
)
|
||||
except WebSocketDisconnect as error:
|
||||
logger.info(f"WebSocket disconnect: {error.code}")
|
||||
except BaseExceptionGroup as egroup:
|
||||
for e in egroup.exceptions:
|
||||
if isinstance(e, WebSocketDisconnect):
|
||||
logger.info(f"WebSocket disconnect: {e.code}")
|
||||
break
|
||||
else: # nocov
|
||||
raise
|
||||
|
||||
app.add_websocket_route(str(STREAM_PATH), model_stream)
|
||||
app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream)
|
||||
|
||||
|
||||
def _make_send_recv_callbacks(
|
||||
|
|
|
@ -11,7 +11,7 @@ from asyncio import (
|
|||
wait,
|
||||
)
|
||||
from collections import Counter
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Sequence
|
||||
from contextlib import AsyncExitStack
|
||||
from logging import getLogger
|
||||
from typing import (
|
||||
|
@ -27,6 +27,7 @@ from uuid import uuid4
|
|||
from weakref import ref as weakref
|
||||
|
||||
from anyio import Semaphore
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from reactpy.config import (
|
||||
REACTPY_ASYNC_RENDERING,
|
||||
|
@ -37,8 +38,10 @@ from reactpy.core._life_cycle_hook import LifeCycleHook
|
|||
from reactpy.core.types import (
|
||||
ComponentType,
|
||||
EventHandlerDict,
|
||||
Key,
|
||||
LayoutEventMessage,
|
||||
LayoutUpdateMessage,
|
||||
VdomChild,
|
||||
VdomDict,
|
||||
VdomJson,
|
||||
)
|
||||
|
@ -189,9 +192,7 @@ class Layout:
|
|||
# wrap the model in a fragment (i.e. tagName="") to ensure components have
|
||||
# a separate node in the model state tree. This could be removed if this
|
||||
# components are given a node in the tree some other way
|
||||
wrapper_model: VdomDict = {"tagName": ""}
|
||||
if raw_model is not None:
|
||||
wrapper_model["children"] = [raw_model]
|
||||
wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]}
|
||||
await self._render_model(exit_stack, old_state, new_state, wrapper_model)
|
||||
except Exception as error:
|
||||
logger.exception(f"Failed to render {component}")
|
||||
|
@ -329,11 +330,11 @@ class Layout:
|
|||
await self._unmount_model_states(list(old_state.children_by_key.values()))
|
||||
return None
|
||||
|
||||
child_type_key_tuples = list(_process_child_type_and_key(raw_children))
|
||||
children_info = _get_children_info(raw_children)
|
||||
|
||||
new_keys = {item[2] for item in child_type_key_tuples}
|
||||
if len(new_keys) != len(raw_children):
|
||||
key_counter = Counter(item[2] for item in child_type_key_tuples)
|
||||
new_keys = {k for _, _, k in children_info}
|
||||
if len(new_keys) != len(children_info):
|
||||
key_counter = Counter(item[2] for item in children_info)
|
||||
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
|
||||
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
|
||||
raise ValueError(msg)
|
||||
|
@ -345,7 +346,7 @@ class Layout:
|
|||
)
|
||||
|
||||
new_state.model.current["children"] = []
|
||||
for index, (child, child_type, key) in enumerate(child_type_key_tuples):
|
||||
for index, (child, child_type, key) in enumerate(children_info):
|
||||
old_child_state = old_state.children_by_key.get(key)
|
||||
if child_type is _DICT_TYPE:
|
||||
old_child_state = old_state.children_by_key.get(key)
|
||||
|
@ -420,17 +421,17 @@ class Layout:
|
|||
new_state: _ModelState,
|
||||
raw_children: list[Any],
|
||||
) -> None:
|
||||
child_type_key_tuples = list(_process_child_type_and_key(raw_children))
|
||||
children_info = _get_children_info(raw_children)
|
||||
|
||||
new_keys = {item[2] for item in child_type_key_tuples}
|
||||
if len(new_keys) != len(raw_children):
|
||||
key_counter = Counter(item[2] for item in child_type_key_tuples)
|
||||
new_keys = {k for _, _, k in children_info}
|
||||
if len(new_keys) != len(children_info):
|
||||
key_counter = Counter(k for _, _, k in children_info)
|
||||
duplicate_keys = [key for key, count in key_counter.items() if count > 1]
|
||||
msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}"
|
||||
raise ValueError(msg)
|
||||
|
||||
new_state.model.current["children"] = []
|
||||
for index, (child, child_type, key) in enumerate(child_type_key_tuples):
|
||||
for index, (child, child_type, key) in enumerate(children_info):
|
||||
if child_type is _DICT_TYPE:
|
||||
child_state = _make_element_model_state(new_state, index, key)
|
||||
await self._render_model(exit_stack, None, child_state, child)
|
||||
|
@ -609,7 +610,7 @@ class _ModelState:
|
|||
key: Any,
|
||||
model: Ref[VdomJson],
|
||||
patch_path: str,
|
||||
children_by_key: dict[str, _ModelState],
|
||||
children_by_key: dict[Key, _ModelState],
|
||||
targets_by_event: dict[str, str],
|
||||
life_cycle_state: _LifeCycleState | None = None,
|
||||
):
|
||||
|
@ -720,16 +721,17 @@ class _ThreadSafeQueue(Generic[_Type]):
|
|||
return value
|
||||
|
||||
|
||||
def _process_child_type_and_key(
|
||||
children: list[Any],
|
||||
) -> Iterator[tuple[Any, _ElementType, Any]]:
|
||||
def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]:
|
||||
infos: list[_ChildInfo] = []
|
||||
for index, child in enumerate(children):
|
||||
if isinstance(child, dict):
|
||||
if child is None:
|
||||
continue
|
||||
elif isinstance(child, dict):
|
||||
child_type = _DICT_TYPE
|
||||
key = child.get("key")
|
||||
elif isinstance(child, ComponentType):
|
||||
child_type = _COMPONENT_TYPE
|
||||
key = getattr(child, "key", None)
|
||||
key = child.key
|
||||
else:
|
||||
child = f"{child}"
|
||||
child_type = _STRING_TYPE
|
||||
|
@ -738,8 +740,12 @@ def _process_child_type_and_key(
|
|||
if key is None:
|
||||
key = index
|
||||
|
||||
yield (child, child_type, key)
|
||||
infos.append((child, child_type, key))
|
||||
|
||||
return infos
|
||||
|
||||
|
||||
_ChildInfo: TypeAlias = tuple[Any, "_ElementType", Key]
|
||||
|
||||
# used in _process_child_type_and_key
|
||||
_ElementType = NewType("_ElementType", int)
|
||||
|
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
from collections.abc import Awaitable
|
||||
from logging import getLogger
|
||||
from typing import Callable
|
||||
from warnings import warn
|
||||
|
||||
from anyio import create_task_group
|
||||
from anyio.abc import TaskGroup
|
||||
|
@ -24,7 +25,9 @@ The event will then trigger an :class:`reactpy.core.proto.EventHandlerType` in a
|
|||
|
||||
|
||||
class Stop(BaseException):
|
||||
"""Stop serving changes and events
|
||||
"""Deprecated
|
||||
|
||||
Stop serving changes and events
|
||||
|
||||
Raising this error will tell dispatchers to gracefully exit. Typically this is
|
||||
called by code running inside a layout to tell it to stop rendering.
|
||||
|
@ -42,7 +45,12 @@ async def serve_layout(
|
|||
async with create_task_group() as task_group:
|
||||
task_group.start_soon(_single_outgoing_loop, layout, send)
|
||||
task_group.start_soon(_single_incoming_loop, task_group, layout, recv)
|
||||
except Stop:
|
||||
except Stop: # nocov
|
||||
warn(
|
||||
"The Stop exception is deprecated and will be removed in a future version",
|
||||
UserWarning,
|
||||
stacklevel=1,
|
||||
)
|
||||
logger.info(f"Stopped serving {layout}")
|
||||
|
||||
|
||||
|
|
|
@ -91,7 +91,7 @@ class LayoutType(Protocol[_Render_co, _Event_contra]):
|
|||
VdomAttributes = Mapping[str, Any]
|
||||
"""Describes the attributes of a :class:`VdomDict`"""
|
||||
|
||||
VdomChild: TypeAlias = "ComponentType | VdomDict | str"
|
||||
VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any"
|
||||
"""A single child element of a :class:`VdomDict`"""
|
||||
|
||||
VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild"
|
||||
|
@ -100,14 +100,7 @@ VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild"
|
|||
|
||||
class _VdomDictOptional(TypedDict, total=False):
|
||||
key: Key | None
|
||||
children: Sequence[
|
||||
# recursive types are not allowed yet:
|
||||
# https://github.com/python/mypy/issues/731
|
||||
ComponentType
|
||||
| dict[str, Any]
|
||||
| str
|
||||
| Any
|
||||
]
|
||||
children: Sequence[ComponentType | VdomChild]
|
||||
attributes: VdomAttributes
|
||||
eventHandlers: EventHandlerDict
|
||||
importSource: ImportSourceDict
|
||||
|
|
|
@ -102,15 +102,6 @@ async def test_simple_layout():
|
|||
)
|
||||
|
||||
|
||||
async def test_component_can_return_none():
|
||||
@reactpy.component
|
||||
def SomeComponent():
|
||||
return None
|
||||
|
||||
async with reactpy.Layout(SomeComponent()) as layout:
|
||||
assert (await layout.render())["model"] == {"tagName": ""}
|
||||
|
||||
|
||||
async def test_nested_component_layout():
|
||||
parent_set_state = reactpy.Ref(None)
|
||||
child_set_state = reactpy.Ref(None)
|
||||
|
@ -1310,3 +1301,45 @@ async def test_concurrent_renders(concurrent_rendering):
|
|||
|
||||
assert child_1_render_count.current == 1
|
||||
assert child_2_render_count.current == 1
|
||||
|
||||
|
||||
async def test_none_does_not_render():
|
||||
@component
|
||||
def Root():
|
||||
return html.div(None, Child())
|
||||
|
||||
@component
|
||||
def Child():
|
||||
return None
|
||||
|
||||
async with layout_runner(Layout(Root())) as runner:
|
||||
tree = await runner.render()
|
||||
assert tree == {
|
||||
"tagName": "",
|
||||
"children": [
|
||||
{"tagName": "div", "children": [{"tagName": "", "children": []}]}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_conditionally_render_none_does_not_trigger_state_change_in_siblings():
|
||||
toggle_condition = Ref()
|
||||
effect_run_count = Ref(0)
|
||||
|
||||
@component
|
||||
def Root():
|
||||
condition, toggle_condition.current = use_toggle(True)
|
||||
return html.div("text" if condition else None, Child())
|
||||
|
||||
@component
|
||||
def Child():
|
||||
@reactpy.use_effect
|
||||
def effect():
|
||||
effect_run_count.current += 1
|
||||
|
||||
async with layout_runner(Layout(Root())) as runner:
|
||||
await runner.render()
|
||||
poll(lambda: effect_run_count.current).until_equals(1)
|
||||
toggle_condition.current()
|
||||
await runner.render()
|
||||
assert effect_run_count.current == 1
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import asyncio
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from jsonpointer import set_pointer
|
||||
|
||||
import reactpy
|
||||
|
@ -31,7 +33,7 @@ def make_send_recv_callbacks(events_to_inject):
|
|||
changes.append(patch)
|
||||
sem.release()
|
||||
if not events_to_inject:
|
||||
raise reactpy.Stop()
|
||||
raise Exception("Stop running")
|
||||
|
||||
async def recv():
|
||||
await sem.acquire()
|
||||
|
@ -90,10 +92,12 @@ def Counter():
|
|||
return reactpy.html.div({EVENT_NAME: handler, "count": count})
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 11), reason="ExceptionGroup not available")
|
||||
async def test_dispatch():
|
||||
events, expected_model = make_events_and_expected_model()
|
||||
changes, send, recv = make_send_recv_callbacks(events)
|
||||
await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1)
|
||||
with pytest.raises(ExceptionGroup):
|
||||
await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1)
|
||||
assert_changes_produce_expected_model(changes, expected_model)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue