mirror of
https://github.com/rio-labs/rio.git
synced 2026-05-04 09:59:16 -05:00
components now refresh immediately, not just after event handlers
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## unreleased
|
||||
|
||||
- Components that depend on session attributes (like `active_page_url`) or
|
||||
session attachments are now automatically rebuilt when those values change
|
||||
- Components are now rebuilt immediately after a state change, not just after
|
||||
event handlers
|
||||
- `NumberInput` can now evaluate math expressions
|
||||
- `PointerEventListener` can now listen to only specific button events
|
||||
- `KeyEventListener` can now listen to only specifc hotkeys
|
||||
|
||||
@@ -261,8 +261,5 @@ class _ButtonInternal(FundamentalComponent):
|
||||
# Trigger the press event
|
||||
await self.call_event_handler(self.on_press)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
_ButtonInternal._unique_id_ = "Button-builtin"
|
||||
|
||||
@@ -148,8 +148,5 @@ class Calendar(FundamentalComponent):
|
||||
DateChangeEvent(self.value),
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
Calendar._unique_id_ = "Calendar-builtin"
|
||||
|
||||
@@ -115,9 +115,6 @@ class Card(FundamentalComponent):
|
||||
# Trigger the press event
|
||||
await self.call_event_handler(self.on_press)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
def _custom_serialize_(self) -> JsonDoc:
|
||||
thm = self.session.theme
|
||||
color = thm._serialize_colorset(self.color)
|
||||
|
||||
@@ -117,9 +117,6 @@ class ColorPicker(FundamentalComponent):
|
||||
ColorChangeEvent(color),
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
def _custom_serialize_(self) -> JsonDoc:
|
||||
return {
|
||||
"color": self.color.srgba,
|
||||
|
||||
@@ -490,6 +490,12 @@ class Component(abc.ABC, metaclass=ComponentMeta):
|
||||
"""
|
||||
Return any additional properties to be serialized, which cannot be
|
||||
deduced automatically from the type annotations.
|
||||
|
||||
Do *not* use this for values that can change rapidly on the client side,
|
||||
like the `text` of a `TextInput`! Because of network latency, it's
|
||||
important that such values are only sent to the client if they have
|
||||
actually changed. But values returned from `_custom_serialize_` are
|
||||
*always* sent to the client.
|
||||
"""
|
||||
return {}
|
||||
|
||||
@@ -706,7 +712,7 @@ class Component(abc.ABC, metaclass=ComponentMeta):
|
||||
is still running. This allows you to e.g. update a progress bar while
|
||||
the operation is still running.
|
||||
"""
|
||||
self.session.create_task(self._force_refresh_())
|
||||
self._mark_all_properties_as_changed_()
|
||||
|
||||
# We need to return a custom Awaitable. We can't use a Task because that
|
||||
# would run regardless of whether the user awaits it or not, and we
|
||||
@@ -728,7 +734,7 @@ class Component(abc.ABC, metaclass=ComponentMeta):
|
||||
from .. import serialization # Avoid circular import
|
||||
|
||||
properties = set(serialization.get_attribute_serializers(type(self)))
|
||||
self.session._changed_properties[self].update(properties)
|
||||
self.session._changed_attributes[self].update(properties)
|
||||
|
||||
async def _force_refresh_(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -48,7 +48,7 @@ class DialogContainer(Component):
|
||||
# Done
|
||||
return content
|
||||
|
||||
# Note that this is NOT `_custom_serialize`. Dialog containers are
|
||||
# Note that this is NOT `_custom_serialize_`. Dialog containers are
|
||||
# high-level on the Python side, but sent to the client as though they were
|
||||
# fundamental. To prevent a whole bunch of custom code in the serializer,
|
||||
# this function handles the serialization of dialog containers.
|
||||
|
||||
@@ -259,8 +259,5 @@ class Dropdown(KeyboardFocusableFundamentalComponent, t.Generic[T]):
|
||||
self.on_change, DropdownChangeEvent(self.selected_value)
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
Dropdown._unique_id_ = "Dropdown-builtin"
|
||||
|
||||
@@ -320,10 +320,10 @@ class FilePickerArea(FundamentalComponent):
|
||||
event_data,
|
||||
)
|
||||
|
||||
# Refresh
|
||||
# If files were added or removed, reassign the `files` property so that
|
||||
# everyone depending on it gets rebuilt
|
||||
if actually_added_files or actually_removed_files:
|
||||
self.force_refresh()
|
||||
await self.session._refresh()
|
||||
self.files = self.files
|
||||
|
||||
|
||||
FilePickerArea._unique_id_ = "FilePickerArea-builtin"
|
||||
|
||||
@@ -170,10 +170,10 @@ class FundamentalComponent(Component):
|
||||
# 2. There's no need to re-build this component, since it's a
|
||||
# FundamentalComponent
|
||||
# That means we should avoid marking this component as dirty.
|
||||
dirty_properties = set(self.session._changed_properties[self])
|
||||
dirty_properties = set(self.session._changed_attributes[self])
|
||||
|
||||
# Update all state properties to reflect the new state
|
||||
for attr_name, attr_value in delta_state.items():
|
||||
setattr(self, attr_name, attr_value)
|
||||
|
||||
self.session._changed_properties[self] = dirty_properties
|
||||
self.session._changed_attributes[self] = dirty_properties
|
||||
|
||||
@@ -241,8 +241,5 @@ class _IconButtonInternal(FundamentalComponent):
|
||||
# Trigger the press event
|
||||
await self.call_event_handler(self.on_press)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
_IconButtonInternal._unique_id_ = "IconButton-builtin"
|
||||
|
||||
@@ -741,9 +741,6 @@ class KeyEventListener(KeyboardFocusableFundamentalComponent):
|
||||
f"{__class__.__name__} encountered unknown message: {msg}"
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
async def _call_key_event_handler(
|
||||
self,
|
||||
handler: rio.EventHandler[E]
|
||||
|
||||
@@ -435,8 +435,5 @@ class CustomListItem(FundamentalComponent):
|
||||
# Trigger the press event
|
||||
await self.call_event_handler(self.on_press)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
CustomListItem._unique_id_ = "CustomListItem-builtin"
|
||||
|
||||
@@ -240,8 +240,5 @@ class ListView(FundamentalComponent):
|
||||
{"selected_items": selected_items}
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
ListView._unique_id_ = "ListView-builtin"
|
||||
|
||||
@@ -390,8 +390,5 @@ class MouseEventListener(FundamentalComponent):
|
||||
f"{__class__.__name__} encountered unknown message: {msg}"
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
MouseEventListener._unique_id_ = "MouseEventListener-builtin"
|
||||
|
||||
@@ -201,8 +201,5 @@ class MultiLineTextInput(KeyboardFocusableFundamentalComponent):
|
||||
MultiLineTextInputConfirmEvent(text),
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
MultiLineTextInput._unique_id_ = "MultiLineTextInput-builtin"
|
||||
|
||||
@@ -310,8 +310,5 @@ class NumberInput(KeyboardFocusableFundamentalComponent):
|
||||
f"Received invalid event from the frontend: {msg}"
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
NumberInput._unique_id_ = "NumberInput-builtin"
|
||||
|
||||
@@ -279,9 +279,6 @@ class PointerEventListener(FundamentalComponent):
|
||||
f"{__class__.__name__} encountered unknown message: {msg}"
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
async def _call_appropriate_event_handler(
|
||||
self,
|
||||
handler: rio.EventHandler[PointerEvent]
|
||||
|
||||
@@ -297,8 +297,5 @@ class SwitcherBar(FundamentalComponent, t.Generic[T]):
|
||||
self.on_change, SwitcherBarChangeEvent(selected_value)
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
SwitcherBar._unique_id_ = "SwitcherBar-builtin"
|
||||
|
||||
@@ -394,7 +394,6 @@ class Table(FundamentalComponent):
|
||||
self.on_press,
|
||||
TablePressEvent._from_message(msg, self),
|
||||
)
|
||||
await self.session._refresh()
|
||||
else:
|
||||
raise ValueError(f"Table encountered an unknown message: {msg}")
|
||||
|
||||
|
||||
@@ -268,8 +268,5 @@ class TextInput(KeyboardFocusableFundamentalComponent):
|
||||
f"Received invalid event from the frontend: {msg}"
|
||||
)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
TextInput._unique_id_ = "TextInput-builtin"
|
||||
|
||||
@@ -32,9 +32,6 @@ class LayoutDisplay(FundamentalComponent):
|
||||
# Trigger the event handler
|
||||
await self.call_event_handler(self.on_layout_change)
|
||||
|
||||
# Refresh the session
|
||||
await self.session._refresh()
|
||||
|
||||
def _validate_delta_state_from_frontend(self, delta_state: JsonDoc) -> None:
|
||||
if not set(delta_state) <= {"component_id"}:
|
||||
raise AssertionError(
|
||||
|
||||
@@ -223,8 +223,7 @@ class LayoutSubpage(rio.Component):
|
||||
# Assign the new value to the Python instance
|
||||
setattr(target, name, value)
|
||||
|
||||
# Components will automatically mark themselves as dirty, but won't
|
||||
# trigger a resync. Do that now.
|
||||
# Wait until the GUI is updated before we update our explanations
|
||||
await self.session._refresh()
|
||||
|
||||
# Update the explanations
|
||||
|
||||
@@ -123,9 +123,6 @@ async def update_and_apply_theme(
|
||||
# Apply it
|
||||
await session._apply_theme(new_theme)
|
||||
|
||||
# Refresh
|
||||
await session._refresh()
|
||||
|
||||
|
||||
def get_source_for_theme(theme: rio.Theme, *, create_theme_pair: bool) -> str:
|
||||
"""
|
||||
|
||||
+9
-1
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from pathlib import Path
|
||||
|
||||
import rio
|
||||
@@ -34,7 +35,14 @@ currently_building_component: rio.Component | None = None
|
||||
currently_building_session: rio.Session | None = None
|
||||
|
||||
|
||||
# Keeps track of components that have a `key`` (and were instantiated during this
|
||||
# Keeps track of components that have a `key` (and were instantiated during this
|
||||
# `build`). The reconciler needs to know about every component with a `key`, and
|
||||
# this is the fastest way to do it.
|
||||
key_to_component: dict[component.Key, rio.Component] = {}
|
||||
|
||||
|
||||
# These keep track of attributes, items, and objects that have been accessed.
|
||||
# (Typically these are reset before a `build` function is called)
|
||||
accessed_objects = set[object]()
|
||||
accessed_attributes = collections.defaultdict[object, set[str]](set)
|
||||
accessed_items = collections.defaultdict[object, set[object]](set)
|
||||
|
||||
@@ -11,6 +11,8 @@ import revel
|
||||
|
||||
import rio
|
||||
|
||||
from .. import global_state
|
||||
|
||||
__all__ = [
|
||||
"ObservableProperty",
|
||||
"AttributeBinding",
|
||||
@@ -65,7 +67,7 @@ class ObservableProperty(abc.ABC, t.Generic[T]):
|
||||
return introspection.typing.annotation_to_string(self._raw_annotation)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _get_affected_sessions(self, instance: T) -> t.Iterable[rio.Session]:
|
||||
def _get_affected_sessions(self, instance: T, /) -> t.Iterable[rio.Session]:
|
||||
raise NotImplementedError
|
||||
|
||||
def __set_name__(self, owner: type, name: str):
|
||||
@@ -81,8 +83,7 @@ class ObservableProperty(abc.ABC, t.Generic[T]):
|
||||
if instance is None:
|
||||
return self
|
||||
|
||||
for session in self._get_affected_sessions(instance):
|
||||
session._accessed_properties[instance].add(self.name)
|
||||
global_state.accessed_attributes[instance].add(self.name)
|
||||
|
||||
# Otherwise get the value assigned to the property in the component
|
||||
# instance
|
||||
@@ -100,7 +101,8 @@ class ObservableProperty(abc.ABC, t.Generic[T]):
|
||||
|
||||
def _on_value_change(self, instance: T, /) -> None:
|
||||
for session in self._get_affected_sessions(instance):
|
||||
session._changed_properties[instance].add(self.name)
|
||||
session._changed_attributes[instance].add(self.name)
|
||||
session._refresh_required_event.set()
|
||||
|
||||
def __set__(self, instance: T, value: object) -> None:
|
||||
if self.readonly:
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
from .. import session, user_settings_module
|
||||
from .. import global_state, session, user_settings_module
|
||||
from . import dataclass
|
||||
|
||||
__all__ = ["SessionAttachments"]
|
||||
@@ -23,7 +23,7 @@ class SessionAttachments:
|
||||
return typ in self._attachments
|
||||
|
||||
def __getitem__(self, typ: type[T]) -> T:
|
||||
self._session._accessed_attachments.add(typ)
|
||||
global_state.accessed_items[self._session].add(typ)
|
||||
|
||||
try:
|
||||
return self._attachments[typ] # type: ignore
|
||||
@@ -33,7 +33,7 @@ class SessionAttachments:
|
||||
def _add(self, value: object, synchronize: bool) -> None:
|
||||
cls = type(value)
|
||||
|
||||
self._session._changed_attachments.add(cls)
|
||||
self._session._changed_items[self._session].add(cls)
|
||||
|
||||
# If the value isn't a UserSettings instance, just assign it and we're
|
||||
# done
|
||||
@@ -77,7 +77,7 @@ class SessionAttachments:
|
||||
# Remove the attachment, propagating any `KeyError`
|
||||
old_value = self._attachments.pop(typ)
|
||||
|
||||
self._session._changed_attachments.add(typ)
|
||||
self._session._changed_items[self._session].add(typ)
|
||||
|
||||
# User settings need special care
|
||||
if not isinstance(old_value, user_settings_module.UserSettings):
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
|
||||
import rio
|
||||
|
||||
from .observable_property import ObservableProperty
|
||||
|
||||
__all__ = ["SessionProperty"]
|
||||
|
||||
|
||||
class SessionProperty:
|
||||
class SessionProperty(ObservableProperty["rio.Session"]):
|
||||
def __set_name__(self, owner: type, name: str):
|
||||
self.name = name
|
||||
|
||||
def __get__(
|
||||
self, session: rio.Session, owner: type | None = None
|
||||
) -> object:
|
||||
return vars(session)[self.name]
|
||||
|
||||
def __set__(self, session: rio.Session, value: object):
|
||||
vars(session)[self.name] = value
|
||||
session._changed_properties[session].add(self.name)
|
||||
def _get_affected_sessions(
|
||||
self, session: rio.Session
|
||||
) -> t.Iterable[rio.Session]:
|
||||
return [session]
|
||||
|
||||
+110
-37
@@ -48,6 +48,7 @@ from .data_models import BuildData, UnittestComponentLayout
|
||||
from .observables import Dataclass
|
||||
from .observables.observable_property import AttributeBinding
|
||||
from .observables.session_attachments import SessionAttachments
|
||||
from .observables.session_property import SessionProperty
|
||||
from .transports import (
|
||||
AbstractTransport,
|
||||
TransportClosedIntentionally,
|
||||
@@ -158,6 +159,8 @@ class Session(unicall.Unicall, Dataclass):
|
||||
[RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646).
|
||||
"""
|
||||
|
||||
_observable_property_factory_ = SessionProperty
|
||||
|
||||
# Type hints so the documentation generator knows which fields exist
|
||||
timezone: tzinfo
|
||||
preferred_languages: t.Sequence[str]
|
||||
@@ -220,28 +223,31 @@ class Session(unicall.Unicall, Dataclass):
|
||||
)
|
||||
)
|
||||
|
||||
# Store which properties and attachments were accessed by `build`
|
||||
# functions
|
||||
self._accessed_properties = utils.WeakKeyDefaultDict[object, set[str]](
|
||||
set
|
||||
)
|
||||
self._accessed_attachments = set[type]()
|
||||
# Sessions automatically rebuild components whenever necessary. To make
|
||||
# this possible, we need to create a background task that waits for a
|
||||
# component to become dirty. It can do that by waiting for this event,
|
||||
# which is triggered by our observables.
|
||||
self._refresh_required_event = asyncio.Event()
|
||||
|
||||
# Map observables to components that accessed them. Whenever an
|
||||
# observable's value changes, the corresponding components will be
|
||||
# rebuilt.
|
||||
self._components_by_accessed_object: weakref.WeakKeyDictionary[
|
||||
object, weakref.WeakSet[rio.Component]
|
||||
] = weakref.WeakKeyDictionary()
|
||||
self._components_by_accessed_property: weakref.WeakKeyDictionary[
|
||||
object, dict[str, weakref.WeakSet[rio.Component]]
|
||||
] = weakref.WeakKeyDictionary()
|
||||
self._components_by_accessed_attachment: collections.defaultdict[
|
||||
type, weakref.WeakSet[rio.Component]
|
||||
] = collections.defaultdict(weakref.WeakSet)
|
||||
self._components_by_accessed_item: weakref.WeakKeyDictionary[
|
||||
object, dict[object, weakref.WeakSet[rio.Component]]
|
||||
] = weakref.WeakKeyDictionary()
|
||||
|
||||
# Keep track of all observables that have changed since the last refresh
|
||||
self._changed_properties = utils.WeakKeyDefaultDict[object, set[str]](
|
||||
self._changed_objects = set[object]()
|
||||
self._changed_attributes = collections.defaultdict[object, set[str]](
|
||||
set
|
||||
)
|
||||
self._changed_attachments = set[type]()
|
||||
self._changed_items = collections.defaultdict[object, set[object]](set)
|
||||
|
||||
# Store the app server
|
||||
self._app_server = app_server_
|
||||
@@ -258,8 +264,8 @@ class Session(unicall.Unicall, Dataclass):
|
||||
self._active_page_url = active_page_url
|
||||
self._active_page_instances_and_path_arguments: tuple[
|
||||
tuple[rio.ComponentPage, dict[str, object]], ...
|
||||
] = tuple()
|
||||
self._active_page_instances: tuple[rio.ComponentPage, ...] = tuple()
|
||||
] = ()
|
||||
self._active_page_instances: tuple[rio.ComponentPage, ...] = ()
|
||||
|
||||
# Components need unique ids, but we don't want them to be globally unique
|
||||
# because then people could guesstimate the approximate number of
|
||||
@@ -423,7 +429,7 @@ class Session(unicall.Unicall, Dataclass):
|
||||
self.http_headers: t.Mapping[str, str] = http_headers
|
||||
|
||||
# Clear the Session properties "changed" by the constructor
|
||||
self._changed_properties.clear()
|
||||
self._changed_attributes.clear()
|
||||
|
||||
# Instantiate the root component
|
||||
global_state.currently_building_component = None
|
||||
@@ -438,6 +444,13 @@ class Session(unicall.Unicall, Dataclass):
|
||||
|
||||
global_state.currently_building_session = None
|
||||
|
||||
self.create_task(self._refresh_whenever_necessary())
|
||||
|
||||
async def _refresh_whenever_necessary(self) -> None:
|
||||
while True:
|
||||
await self._refresh_required_event.wait()
|
||||
await self._refresh()
|
||||
|
||||
async def __send_message(self, message: str) -> None:
|
||||
await self._rio_transport.send_if_possible(message)
|
||||
|
||||
@@ -1192,7 +1205,14 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
if component in self._newly_created_components:
|
||||
results.append("newly created")
|
||||
|
||||
for obj, changed_attrs in self._changed_properties.items():
|
||||
for obj in self._changed_objects:
|
||||
try:
|
||||
if component in self._components_by_accessed_object[obj]:
|
||||
results.append(f"object {obj!r} changed")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for obj, changed_attrs in self._changed_attributes.items():
|
||||
if obj is component and isinstance(
|
||||
obj, fundamental_component.FundamentalComponent
|
||||
):
|
||||
@@ -1214,12 +1234,22 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for typ in self._changed_attachments:
|
||||
for obj, changed_items in self._changed_attributes.items():
|
||||
try:
|
||||
if component in self._components_by_accessed_attachment[typ]:
|
||||
results.append(f"session attachment {typ!r} changed")
|
||||
dependents_by_changed_item = self._components_by_accessed_item[
|
||||
obj
|
||||
]
|
||||
except KeyError:
|
||||
pass
|
||||
continue
|
||||
|
||||
for changed_item in changed_items:
|
||||
try:
|
||||
if component in dependents_by_changed_item[changed_item]:
|
||||
results.append(
|
||||
f"item {changed_item!r} of {obj} changed"
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
@@ -1233,8 +1263,12 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
# Add newly instantiated components
|
||||
components_to_build.update(self._newly_created_components)
|
||||
|
||||
# Add components that depend on observable objects that have changed
|
||||
for obj in self._changed_objects:
|
||||
components_to_build.update(self._components_by_accessed_object[obj])
|
||||
|
||||
# Add components that depend on properties that have changed
|
||||
for obj, changed_attrs in self._changed_properties.items():
|
||||
for obj, changed_attrs in self._changed_attributes.items():
|
||||
if not changed_attrs:
|
||||
continue
|
||||
|
||||
@@ -1260,14 +1294,32 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Add components that depend on attachments that have changed
|
||||
for typ in self._changed_attachments:
|
||||
# Add components that depend on items that have changed
|
||||
for obj, changed_items in self._changed_items.items():
|
||||
if not changed_items:
|
||||
continue
|
||||
|
||||
# If the object is a FundamentalComponent, add it too. It doesn't
|
||||
# have a `build` method, but it obviously does depend on its own
|
||||
# properties.
|
||||
if isinstance(obj, fundamental_component.FundamentalComponent):
|
||||
components_to_build.add(obj)
|
||||
|
||||
# Add all components that depend on this attribute
|
||||
try:
|
||||
components_to_build.update(
|
||||
self._components_by_accessed_attachment[typ]
|
||||
)
|
||||
dependents_by_changed_item = self._components_by_accessed_item[
|
||||
obj
|
||||
]
|
||||
except KeyError:
|
||||
pass
|
||||
continue
|
||||
|
||||
for changed_item in changed_items:
|
||||
try:
|
||||
components_to_build.update(
|
||||
dependents_by_changed_item[changed_item]
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return components_to_build
|
||||
|
||||
@@ -1286,13 +1338,14 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
|
||||
# If the event handler made the component dirty again, undo
|
||||
# it
|
||||
self._changed_properties.pop(component, None)
|
||||
self._changed_attributes.pop(component, None)
|
||||
|
||||
# Call the `build` method
|
||||
global_state.currently_building_component = component
|
||||
global_state.currently_building_session = self
|
||||
self._accessed_properties.clear()
|
||||
self._accessed_attachments.clear()
|
||||
global_state.accessed_objects.clear()
|
||||
global_state.accessed_attributes.clear()
|
||||
global_state.accessed_items.clear()
|
||||
|
||||
build_result = utils.safe_build(component.build)
|
||||
|
||||
@@ -1303,7 +1356,10 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
global_state.key_to_component = {}
|
||||
|
||||
# Process the state that was accessed by the `build` method
|
||||
for obj, accessed_attrs in self._accessed_properties.items():
|
||||
for obj in global_state.accessed_objects:
|
||||
self._components_by_accessed_object[obj].add(component)
|
||||
|
||||
for obj, accessed_attrs in global_state.accessed_attributes.items():
|
||||
# Sometimes a `build` method indirectly accesses the state of a
|
||||
# child component. For example, calling `Row.add()` "accesses"
|
||||
# `Row.children`. This can lead to an infinite loop: Because the
|
||||
@@ -1333,10 +1389,25 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
weakref.WeakSet([component])
|
||||
)
|
||||
|
||||
for key in self._accessed_attachments:
|
||||
self._components_by_accessed_attachment[key].add(component)
|
||||
for obj, accessed_items in global_state.accessed_items.items():
|
||||
try:
|
||||
components_by_accessed_item = self._components_by_accessed_item[
|
||||
obj
|
||||
]
|
||||
except KeyError:
|
||||
components_by_accessed_item = (
|
||||
self._components_by_accessed_item.setdefault(obj, {})
|
||||
)
|
||||
|
||||
if component in self._changed_properties:
|
||||
for accessed_item in accessed_items:
|
||||
try:
|
||||
components_by_accessed_item[accessed_item].add(component)
|
||||
except KeyError:
|
||||
components_by_accessed_item[accessed_item] = (
|
||||
weakref.WeakSet([component])
|
||||
)
|
||||
|
||||
if component in self._changed_attributes:
|
||||
raise RuntimeError(
|
||||
f"The `build()` method of the component `{component}`"
|
||||
f" has changed the component's state. This isn't"
|
||||
@@ -1437,14 +1508,16 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
# Build all dirty components
|
||||
while True:
|
||||
# Update the properties_to_serialize
|
||||
for obj, changed_properties in self._changed_properties.items():
|
||||
for obj, changed_properties in self._changed_attributes.items():
|
||||
properties_to_serialize[obj].update(changed_properties)
|
||||
|
||||
# Collect all dirty components
|
||||
components_to_build = self._collect_components_to_build()
|
||||
self._newly_created_components.clear()
|
||||
self._changed_properties.clear()
|
||||
self._changed_attachments.clear()
|
||||
self._changed_objects.clear()
|
||||
self._changed_attributes.clear()
|
||||
self._changed_items.clear()
|
||||
self._refresh_required_event.clear()
|
||||
|
||||
# If there are no components to build, we're done
|
||||
if not components_to_build:
|
||||
@@ -1984,7 +2057,7 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
if not values_equal(old_value, new_value):
|
||||
changed_properties.add(prop_name)
|
||||
|
||||
self._changed_properties[old_component].update(changed_properties)
|
||||
self._changed_attributes[old_component].update(changed_properties)
|
||||
|
||||
# Now combine the old and new dictionaries
|
||||
#
|
||||
|
||||
@@ -337,4 +337,4 @@ async def test_binding_to_differently_named_attribute():
|
||||
|
||||
text_input.text = "hi"
|
||||
|
||||
assert "foo" in test_client.session._changed_properties[root_component]
|
||||
assert "foo" in test_client.session._changed_attributes[root_component]
|
||||
|
||||
@@ -8,9 +8,10 @@ import rio.testing
|
||||
[
|
||||
("window_width", 61.23),
|
||||
("window_height", 61.23),
|
||||
("_active_page_url", "https://foo.bar"),
|
||||
("_active_page_url", rio.URL("https://foo.bar")),
|
||||
("_active_page_instances", ()),
|
||||
],
|
||||
ids=str,
|
||||
)
|
||||
async def test_session_property_change(attr_name: str, new_value: object):
|
||||
class TestComponent(rio.Component):
|
||||
@@ -20,16 +21,15 @@ async def test_session_property_change(attr_name: str, new_value: object):
|
||||
|
||||
async with rio.testing.DummyClient(TestComponent) as test_client:
|
||||
test_component = test_client.get_component(TestComponent)
|
||||
text_component = test_client.get_component(rio.Text)
|
||||
|
||||
test_client._received_messages.clear()
|
||||
setattr(test_client.session, attr_name, new_value)
|
||||
await test_client.refresh()
|
||||
|
||||
assert test_client._last_updated_components == {
|
||||
test_component,
|
||||
text_component,
|
||||
}
|
||||
# Note: The `Text` component isn't necessarily updated, because the
|
||||
# value we assigned might be the same as before, so the reconciler
|
||||
# doesn't consider it dirty
|
||||
assert test_component in test_client._last_updated_components
|
||||
|
||||
|
||||
async def test_session_attachment_change():
|
||||
|
||||
+35
-1
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
import rio.testing
|
||||
|
||||
|
||||
@@ -207,9 +209,9 @@ async def test_binding_doesnt_update_children() -> None:
|
||||
text_input = test_client.get_component(rio.TextInput)
|
||||
text = test_client.get_component(rio.Text)
|
||||
|
||||
# Note: `text_input._on_message_` automatically triggers a refresh
|
||||
test_client._received_messages.clear()
|
||||
await text_input._on_message_({"type": "confirm", "text": "hello"})
|
||||
await test_client.refresh()
|
||||
|
||||
# Only the Text component has changed in this rebuild
|
||||
assert test_client._last_updated_components == {root_component, text}
|
||||
@@ -234,6 +236,38 @@ async def test_add_method_doesnt_count_as_attribute_access():
|
||||
pass # If we made it this far, then there's no infinite loop.
|
||||
|
||||
|
||||
async def test_automatic_refresh():
|
||||
"""
|
||||
Test whether Rio automatically refreshes after a state change
|
||||
"""
|
||||
updated_event = asyncio.Event()
|
||||
|
||||
class TestComponent(rio.Component):
|
||||
text: str = "hi"
|
||||
|
||||
@rio.event.on_mount
|
||||
async def on_mount(self):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
self.text = "bye"
|
||||
test_client._received_messages.clear()
|
||||
updated_event.set()
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
def build(self):
|
||||
return rio.Text(self.text)
|
||||
|
||||
async with rio.testing.DummyClient(TestComponent) as test_client:
|
||||
await updated_event.wait()
|
||||
|
||||
# Yield control so that Rio has a chance to refresh
|
||||
await asyncio.sleep(0)
|
||||
|
||||
text_component = test_client.get_component(rio.Text)
|
||||
assert text_component in test_client._last_updated_components
|
||||
|
||||
|
||||
async def test_value_change_from_frontend():
|
||||
"""
|
||||
When the frontend changes the state of a FundamentalComponent, we don't want
|
||||
|
||||
+34
-38
@@ -2,7 +2,7 @@ import typing as t
|
||||
|
||||
import pytest
|
||||
|
||||
import rio.app_server
|
||||
import rio.testing
|
||||
|
||||
|
||||
class FakeComponent(rio.Component):
|
||||
@@ -177,7 +177,7 @@ PAGES = [
|
||||
# URLs
|
||||
],
|
||||
)
|
||||
def test_redirects(
|
||||
async def test_redirects(
|
||||
relative_url_before_redirects: str,
|
||||
relative_url_after_redirects_should: str,
|
||||
) -> None:
|
||||
@@ -187,28 +187,21 @@ def test_redirects(
|
||||
"""
|
||||
# Create a fake session. It contains everything used by the routing system.
|
||||
app = rio.App(pages=PAGES)
|
||||
app_server = rio.app_server.TestingServer(app)
|
||||
session = app_server.create_dummy_session()
|
||||
|
||||
# Determine the final URL
|
||||
active_pages_and_path_arguments, absolute_url_after_redirects_is = (
|
||||
rio.routing.check_page_guards(
|
||||
session,
|
||||
session._base_url.join(rio.URL(relative_url_before_redirects)),
|
||||
async with rio.testing.DummyClient(app) as client:
|
||||
client.session.navigate_to(relative_url_before_redirects)
|
||||
|
||||
# Verify the final URL
|
||||
absolute_url_after_redirects_should = client.session._base_url.join(
|
||||
rio.URL(relative_url_after_redirects_should)
|
||||
)
|
||||
assert (
|
||||
client.session.active_page_url
|
||||
== absolute_url_after_redirects_should
|
||||
)
|
||||
)
|
||||
|
||||
# Verify the final URL
|
||||
absolute_url_after_redirects_should = session._base_url.join(
|
||||
rio.URL(relative_url_after_redirects_should)
|
||||
)
|
||||
|
||||
assert (
|
||||
absolute_url_after_redirects_is == absolute_url_after_redirects_should
|
||||
)
|
||||
|
||||
|
||||
def test_url_parameter_parsing_failure() -> None:
|
||||
async def test_url_parameter_parsing_failure() -> None:
|
||||
def build_int_page(path_param: int):
|
||||
return rio.Text(str(path_param))
|
||||
|
||||
@@ -228,21 +221,25 @@ def test_url_parameter_parsing_failure() -> None:
|
||||
)
|
||||
|
||||
app = rio.App(pages=(int_page, float_page))
|
||||
app_server = rio.app_server.TestingServer(app)
|
||||
session = app_server.create_dummy_session()
|
||||
async with rio.testing.DummyClient(app) as client:
|
||||
session = client.session
|
||||
|
||||
active_pages_and_path_arguments, _ = rio.routing.check_page_guards(
|
||||
session,
|
||||
session._base_url.join(rio.URL("/3.5")),
|
||||
)
|
||||
session.navigate_to("/3.5")
|
||||
|
||||
assert active_pages_and_path_arguments is not None
|
||||
assert len(active_pages_and_path_arguments) == 1
|
||||
assert active_pages_and_path_arguments[0][0] == float_page
|
||||
assert active_pages_and_path_arguments[0][1] == {"path_param": 3.5}
|
||||
expected_url = session._base_url.join(rio.URL("/3.5"))
|
||||
assert session.active_page_url == expected_url
|
||||
|
||||
assert len(session._active_page_instances_and_path_arguments) == 1
|
||||
assert (
|
||||
session._active_page_instances_and_path_arguments[0][0]
|
||||
== float_page
|
||||
)
|
||||
assert session._active_page_instances_and_path_arguments[0][1] == {
|
||||
"path_param": 3.5
|
||||
}
|
||||
|
||||
|
||||
def test_redirect_offsite() -> None:
|
||||
async def test_redirect_offsite() -> None:
|
||||
"""
|
||||
Redirect to a site other than this app.
|
||||
"""
|
||||
@@ -253,17 +250,16 @@ def test_redirect_offsite() -> None:
|
||||
)
|
||||
|
||||
app = rio.App(pages=(page,))
|
||||
app_server = rio.app_server.TestingServer(app)
|
||||
session = app_server.create_dummy_session()
|
||||
|
||||
external_url = rio.URL("http://example.com")
|
||||
|
||||
active_pages_and_path_arguments, absolute_url_after_redirects = (
|
||||
rio.routing.check_page_guards(
|
||||
session,
|
||||
external_url,
|
||||
async with rio.testing.DummyClient(app) as client:
|
||||
active_pages_and_path_arguments, absolute_url_after_redirects = (
|
||||
rio.routing.check_page_guards(
|
||||
client.session,
|
||||
external_url,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert active_pages_and_path_arguments is None
|
||||
assert absolute_url_after_redirects == external_url
|
||||
|
||||
Reference in New Issue
Block a user