mirror of
https://github.com/rio-labs/rio.git
synced 2026-02-12 16:48:59 -06:00
track dependencies of build functions
This commit is contained in:
@@ -155,6 +155,12 @@ class ComponentMeta(RioDataclassMeta):
|
||||
session = global_state.currently_building_session
|
||||
component._session_ = session
|
||||
|
||||
# Temporarily disable recording of accessed properties. We only want to
|
||||
# record what's accessed by the `build` function, not what's accessed by
|
||||
# Component constructors.
|
||||
record_accessed_observables = session._record_accessed_observables
|
||||
session._record_accessed_observables = False
|
||||
|
||||
# Create a unique ID for this component
|
||||
component._id_ = session._next_free_component_id
|
||||
session._next_free_component_id += 1
|
||||
@@ -191,8 +197,6 @@ class ComponentMeta(RioDataclassMeta):
|
||||
# them can be passed on correctly.
|
||||
session._weak_components_by_id[component._id_] = component
|
||||
|
||||
session._dirty_components.add(component)
|
||||
|
||||
# Some events need attention right after the component is created
|
||||
for event_tag, event_handlers in component._rio_event_handlers_.items():
|
||||
# Don't register an empty list of handlers, since that would
|
||||
@@ -231,6 +235,13 @@ class ComponentMeta(RioDataclassMeta):
|
||||
|
||||
component._properties_assigned_after_creation_.clear()
|
||||
|
||||
# Register it as a newly created component. This causes it to be built
|
||||
# for the first time, which otherwise wouldn't happen since this
|
||||
# component doesn't depend on any state yet.
|
||||
session._newly_created_components.add(component)
|
||||
|
||||
session._record_accessed_observables = record_accessed_observables
|
||||
|
||||
return component
|
||||
|
||||
|
||||
|
||||
@@ -706,7 +706,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.session.create_task(self._force_refresh_())
|
||||
|
||||
# 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
|
||||
@@ -724,14 +724,19 @@ class Component(abc.ABC, metaclass=ComponentMeta):
|
||||
|
||||
return BackwardsCompat() # type: ignore
|
||||
|
||||
async def _force_refresh(self) -> None:
|
||||
def _mark_all_properties_as_changed_(self) -> None:
|
||||
from .. import serialization # Avoid circular import
|
||||
|
||||
properties = set(serialization.get_attribute_serializers(type(self)))
|
||||
self.session._changed_properties[self].update(properties)
|
||||
|
||||
async def _force_refresh_(self) -> None:
|
||||
"""
|
||||
This function primarily exists for unit tests. Tests often need to wait
|
||||
until the GUI is refreshed, and the public `force_refresh()` doesn't
|
||||
allow that.
|
||||
"""
|
||||
self.session._dirty_components.add(self)
|
||||
|
||||
self._mark_all_properties_as_changed_()
|
||||
await self.session._refresh()
|
||||
|
||||
def _get_debug_details_(self) -> dict[str, t.Any]:
|
||||
|
||||
@@ -192,7 +192,7 @@ class DefaultRootComponent(component.Component):
|
||||
margin_y=1,
|
||||
align_x=0.5,
|
||||
),
|
||||
tip="Only visible in debug mode. Follow the link for a guide on how to replace this navigation.",
|
||||
tip="Follow the link for a guide on how to replace this navigation. (Only visible in debug mode)",
|
||||
position="right",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ class FilePickerArea(FundamentalComponent):
|
||||
|
||||
# Refresh
|
||||
if actually_added_files or actually_removed_files:
|
||||
self.session._dirty_components.add(self)
|
||||
self.force_refresh()
|
||||
await self.session._refresh()
|
||||
|
||||
|
||||
|
||||
@@ -170,11 +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.
|
||||
was_already_dirty = self in self.session._dirty_components
|
||||
dirty_properties = set(self.session._changed_properties[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)
|
||||
|
||||
if not was_already_dirty:
|
||||
self.session._dirty_components.discard(self)
|
||||
self.session._changed_properties[self] = dirty_properties
|
||||
|
||||
@@ -49,7 +49,6 @@ class GraphStore:
|
||||
# Already connected to an editor?
|
||||
if self._graph is not None:
|
||||
self._graph.children = []
|
||||
self._graph.session._dirty_components.add(self._graph)
|
||||
|
||||
# Connect to the new editor
|
||||
self._graph = editor
|
||||
@@ -70,7 +69,7 @@ class GraphStore:
|
||||
|
||||
# Tell the graph editor to rebuild
|
||||
if self._graph is not None:
|
||||
self._graph.session._dirty_components.add(self._graph)
|
||||
self._graph.force_refresh()
|
||||
|
||||
|
||||
@t.final
|
||||
|
||||
@@ -123,12 +123,6 @@ async def update_and_apply_theme(
|
||||
# Apply it
|
||||
await session._apply_theme(new_theme)
|
||||
|
||||
# The application itself isn't enough, because some components will have
|
||||
# read theme values and used them to set e.g. their corner radii. Dirty
|
||||
# every component to force a full rebuild.
|
||||
for component in session._weak_components_by_id.values():
|
||||
session._dirty_components.add(component)
|
||||
|
||||
# Refresh
|
||||
await session._refresh()
|
||||
|
||||
|
||||
@@ -79,7 +79,9 @@ def serialize_json(data: Jsonable) -> str:
|
||||
return json.dumps(data, default=_serialize_special_types)
|
||||
|
||||
|
||||
def serialize_and_host_component(component: rio.Component) -> JsonDoc:
|
||||
def serialize_and_host_component(
|
||||
component: rio.Component, changed_properties: t.Iterable[str]
|
||||
) -> JsonDoc:
|
||||
"""
|
||||
Serializes the component, non-recursively. Children are serialized just by
|
||||
their `_id`.
|
||||
@@ -143,10 +145,16 @@ def serialize_and_host_component(component: rio.Component) -> JsonDoc:
|
||||
# the frontend.
|
||||
if isinstance(component, fundamental_component.FundamentalComponent):
|
||||
sess = component.session
|
||||
serializers = get_attribute_serializers(type(component))
|
||||
|
||||
for name in changed_properties:
|
||||
try:
|
||||
serializer = serializers[name]
|
||||
except KeyError:
|
||||
# This happens for properties inherited from Component, like
|
||||
# `margin` or `min_width`
|
||||
continue
|
||||
|
||||
for name, serializer in get_attribute_serializers(
|
||||
type(component)
|
||||
).items():
|
||||
result[name] = serializer(sess, getattr(component, name))
|
||||
|
||||
# Encode any internal additional state. Doing it this late allows the
|
||||
|
||||
491
rio/session.py
491
rio/session.py
@@ -63,6 +63,21 @@ class WontSerialize(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ObservableSessionProperty:
|
||||
def __set_name__(self, owner: type, name: str):
|
||||
self.name = name
|
||||
|
||||
def __get__(self, session: Session, owner: type | None = None) -> object:
|
||||
if session._record_accessed_observables:
|
||||
session._accessed_properties[session].add(self.name)
|
||||
|
||||
return vars(session)[self.name]
|
||||
|
||||
def __set__(self, session: Session, value: object):
|
||||
vars(session)[self.name] = value
|
||||
session._changed_properties[session].add(self.name)
|
||||
|
||||
|
||||
class Session(unicall.Unicall):
|
||||
"""
|
||||
Represents a single client connection to the app.
|
||||
@@ -164,15 +179,15 @@ class Session(unicall.Unicall):
|
||||
screen_width: float
|
||||
screen_height: float
|
||||
|
||||
window_width: float
|
||||
window_height: float
|
||||
window_width: float = ObservableSessionProperty() # type: ignore
|
||||
window_height: float = ObservableSessionProperty() # type: ignore
|
||||
|
||||
pixels_per_font_height: float
|
||||
scroll_bar_size: float
|
||||
|
||||
primary_pointer_type: t.Literal["mouse", "touch"]
|
||||
|
||||
theme: rio.Theme
|
||||
theme: rio.Theme = ObservableSessionProperty() # type: ignore
|
||||
|
||||
http_headers: t.Mapping[str, str]
|
||||
|
||||
@@ -215,48 +230,6 @@ class Session(unicall.Unicall):
|
||||
)
|
||||
|
||||
self._app_server = app_server_
|
||||
self.timezone = timezone
|
||||
|
||||
self.preferred_languages = tuple(preferred_languages)
|
||||
assert self.preferred_languages, "Preferred languages must not be empty"
|
||||
|
||||
# General locale information
|
||||
self._month_names_long = month_names_long
|
||||
self._day_names_long = day_names_long
|
||||
self._date_format_string = date_format_string
|
||||
self._first_day_of_week = first_day_of_week
|
||||
|
||||
# Separators for number rendering
|
||||
self._decimal_separator = decimal_separator
|
||||
self._thousands_separator = thousands_separator
|
||||
|
||||
# Device information
|
||||
self.screen_width = screen_width
|
||||
self.screen_height = screen_height
|
||||
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
|
||||
self.pixels_per_font_height = pixels_per_font_height
|
||||
self.scroll_bar_size = scroll_bar_size
|
||||
|
||||
self.primary_pointer_type = primary_pointer_type
|
||||
|
||||
self._is_maximized = False
|
||||
self._is_fullscreen = False
|
||||
|
||||
# The URL the app's root is accessible at from the outside. Note that
|
||||
# this value slightly differs from the `base_url` in the app server. The
|
||||
# app server's parameter is optional, as a base URL can be guessed from
|
||||
# the HTTP request when a session is created. Thus, this value here is
|
||||
# always a valid URL and never `None`.
|
||||
assert base_url.is_absolute(), base_url
|
||||
assert not base_url.query, base_url
|
||||
assert not base_url.fragment, base_url
|
||||
assert str(base_url).islower(), base_url
|
||||
self._base_url = base_url
|
||||
|
||||
self.theme = theme_
|
||||
|
||||
# This attribute is initialized after the Session has been instantiated,
|
||||
# because the Session must already exist when the Component is created.
|
||||
@@ -362,14 +335,37 @@ class Session(unicall.Unicall):
|
||||
int, rio.Component
|
||||
] = weakref.WeakValueDictionary()
|
||||
|
||||
# Keep track of all dirty components, once again, weakly.
|
||||
#
|
||||
# Components are dirty if any of their properties have changed since the
|
||||
# last time they were built. Newly created components are also
|
||||
# considered dirty.
|
||||
self._dirty_components: weakref.WeakSet[rio.Component] = (
|
||||
weakref.WeakSet()
|
||||
# Stores newly created components. These need to be built/refreshed.
|
||||
self._newly_created_components = set[rio.Component]()
|
||||
|
||||
# Store which properties and attachments were accessed by `build`
|
||||
# functions
|
||||
self._accessed_properties = utils.WeakKeyDefaultDict[object, set[str]](
|
||||
set
|
||||
)
|
||||
self._accessed_attachments = set[type]()
|
||||
|
||||
# As long as this is `True`, `StateProperties` will record whether they
|
||||
# were accessed or not. This is necessary because we only want to record
|
||||
# what the `build` function itself accessed, so recording must be
|
||||
# disabled in `Component` constructors.
|
||||
self._record_accessed_observables: bool = False
|
||||
|
||||
# Map observables to components that accessed them. Whenever an
|
||||
# observable's value changes, the corresponding components will be
|
||||
# rebuilt.
|
||||
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)
|
||||
|
||||
# Keep track of all observables that have changed since the last refresh
|
||||
self._changed_properties = utils.WeakKeyDefaultDict[object, set[str]](
|
||||
set
|
||||
)
|
||||
self._changed_attachments = set[type]()
|
||||
|
||||
# HTML components have source code which must be evaluated by the client
|
||||
# exactly once. Keep track of which components have already sent their
|
||||
@@ -392,6 +388,49 @@ class Session(unicall.Unicall):
|
||||
# Note: These are initialized by the AppServer.
|
||||
self._attachments = session_attachments.SessionAttachments(self)
|
||||
|
||||
self.timezone = timezone
|
||||
|
||||
self.preferred_languages = tuple(preferred_languages)
|
||||
assert self.preferred_languages, "Preferred languages must not be empty"
|
||||
|
||||
# General locale information
|
||||
self._month_names_long = month_names_long
|
||||
self._day_names_long = day_names_long
|
||||
self._date_format_string = date_format_string
|
||||
self._first_day_of_week = first_day_of_week
|
||||
|
||||
# Separators for number rendering
|
||||
self._decimal_separator = decimal_separator
|
||||
self._thousands_separator = thousands_separator
|
||||
|
||||
# Device information
|
||||
self.screen_width = screen_width
|
||||
self.screen_height = screen_height
|
||||
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
|
||||
self.pixels_per_font_height = pixels_per_font_height
|
||||
self.scroll_bar_size = scroll_bar_size
|
||||
|
||||
self.primary_pointer_type = primary_pointer_type
|
||||
|
||||
self._is_maximized = False
|
||||
self._is_fullscreen = False
|
||||
|
||||
# The URL the app's root is accessible at from the outside. Note that
|
||||
# this value slightly differs from the `base_url` in the app server. The
|
||||
# app server's parameter is optional, as a base URL can be guessed from
|
||||
# the HTTP request when a session is created. Thus, this value here is
|
||||
# always a valid URL and never `None`.
|
||||
assert base_url.is_absolute(), base_url
|
||||
assert not base_url.query, base_url
|
||||
assert not base_url.fragment, base_url
|
||||
assert str(base_url).islower(), base_url
|
||||
self._base_url = base_url
|
||||
|
||||
self.theme = theme_
|
||||
|
||||
# Information about the visitor
|
||||
self._client_ip: str = client_ip
|
||||
self._client_port: int = client_port
|
||||
@@ -922,7 +961,7 @@ window.resizeTo(screen.availWidth, screen.availHeight);
|
||||
|
||||
# Display and discard exceptions
|
||||
except Exception:
|
||||
print("Exception in event handler:")
|
||||
revel.error("Exception in event handler:")
|
||||
traceback.print_exc()
|
||||
return
|
||||
|
||||
@@ -934,7 +973,7 @@ window.resizeTo(screen.availWidth, screen.availHeight);
|
||||
try:
|
||||
await result
|
||||
except Exception:
|
||||
print("Exception in event handler:")
|
||||
revel.error("Exception in event handler:")
|
||||
traceback.print_exc()
|
||||
|
||||
await self._refresh()
|
||||
@@ -1086,10 +1125,6 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
page for page, _ in active_page_instances_and_path_arguments
|
||||
)
|
||||
|
||||
# Dirty all PageViews to force a rebuild
|
||||
for page_view in self._page_views:
|
||||
self._dirty_components.add(page_view)
|
||||
|
||||
async def refresh_and_update_url() -> None:
|
||||
await self._refresh()
|
||||
|
||||
@@ -1154,10 +1189,55 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
assert isinstance(coro, t.Coroutine)
|
||||
self.create_task(coro)
|
||||
|
||||
def _collect_components_to_build(self) -> set[rio.Component]:
|
||||
components_to_build = set[rio.Component]()
|
||||
|
||||
# Add newly instantiated components
|
||||
components_to_build.update(self._newly_created_components)
|
||||
|
||||
# Add components that depend on properties that have changed
|
||||
for obj, changed_attrs in self._changed_properties.items():
|
||||
if not changed_attrs:
|
||||
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:
|
||||
dependents_by_changed_attr = (
|
||||
self._components_by_accessed_property[obj]
|
||||
)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
for changed_attr in changed_attrs:
|
||||
try:
|
||||
components_to_build.update(
|
||||
dependents_by_changed_attr[changed_attr]
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Add components that depend on attachments that have changed
|
||||
for typ in self._changed_attachments:
|
||||
try:
|
||||
components_to_build.update(
|
||||
self._components_by_accessed_attachment.pop(typ)
|
||||
)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
return components_to_build
|
||||
|
||||
def _refresh_sync(
|
||||
self,
|
||||
) -> tuple[
|
||||
set[rio.Component],
|
||||
dict[object, set[str]],
|
||||
t.Iterable[rio.Component],
|
||||
t.Iterable[rio.Component],
|
||||
]:
|
||||
@@ -1181,119 +1261,167 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
collections.Counter()
|
||||
)
|
||||
|
||||
# Keep track of all changed properties for each component. The
|
||||
# serializer will need this information later
|
||||
properties_to_serialize = collections.defaultdict[object, set[str]](set)
|
||||
|
||||
# Keep track of of previous child components
|
||||
old_children_in_build_boundary_for_visited_children = {}
|
||||
|
||||
# Build all dirty components
|
||||
while self._dirty_components:
|
||||
component = self._dirty_components.pop()
|
||||
while True:
|
||||
# Update the properties_to_serialize
|
||||
for obj, changed_properties in self._changed_properties.items():
|
||||
properties_to_serialize[obj].update(changed_properties)
|
||||
|
||||
# Remember that this component has been visited
|
||||
visited_components[component] += 1
|
||||
# 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()
|
||||
|
||||
# Catch deep recursions and abort
|
||||
build_count = visited_components[component]
|
||||
if build_count > 5:
|
||||
raise RecursionError(
|
||||
f"The component `{component}` has been rebuilt"
|
||||
f" {build_count} times during a single refresh. This is"
|
||||
f" likely because one of your components' `build` methods"
|
||||
f" is modifying the component's state"
|
||||
# If there are no components to build, we're done
|
||||
if not components_to_build:
|
||||
break
|
||||
|
||||
for component in components_to_build:
|
||||
# Remember that this component has been visited
|
||||
visited_components[component] += 1
|
||||
|
||||
# Catch deep recursions and abort
|
||||
build_count = visited_components[component]
|
||||
if build_count > 5:
|
||||
raise RecursionError(
|
||||
f"The component `{component}` has been rebuilt"
|
||||
f" {build_count} times during a single refresh. This is"
|
||||
f" likely because one of your components' `build`"
|
||||
f" methods is modifying the component's state"
|
||||
)
|
||||
|
||||
# Fundamental components require no further treatment
|
||||
if isinstance(
|
||||
component, fundamental_component.FundamentalComponent
|
||||
):
|
||||
continue
|
||||
|
||||
# Trigger the `on_populate` event, if it hasn't already. Since
|
||||
# the whole point of this event is to fetch data and modify the
|
||||
# component's state, wait for it to finish if it is synchronous.
|
||||
if not component._on_populate_triggered_:
|
||||
component._on_populate_triggered_ = True
|
||||
|
||||
for handler, _ in component._rio_event_handlers_[
|
||||
rio.event.EventTag.ON_POPULATE
|
||||
]:
|
||||
self._call_event_handler_sync(handler, component)
|
||||
|
||||
# If the event handler made the component dirty again, undo
|
||||
# it
|
||||
self._changed_properties.pop(component, None)
|
||||
|
||||
# Others need to be built
|
||||
global_state.currently_building_component = component
|
||||
global_state.currently_building_session = self
|
||||
self._record_accessed_observables = True
|
||||
self._accessed_properties.clear()
|
||||
self._accessed_attachments.clear()
|
||||
|
||||
build_result = utils.safe_build(component.build)
|
||||
|
||||
global_state.currently_building_component = None
|
||||
global_state.currently_building_session = None
|
||||
self._record_accessed_observables = False
|
||||
|
||||
key_to_component = global_state.key_to_component
|
||||
global_state.key_to_component = {}
|
||||
|
||||
for obj, accessed_attrs in self._accessed_properties.items():
|
||||
try:
|
||||
components_by_accessed_attr = (
|
||||
self._components_by_accessed_property[obj]
|
||||
)
|
||||
except KeyError:
|
||||
components_by_accessed_attr = (
|
||||
self._components_by_accessed_property.setdefault(
|
||||
obj, {}
|
||||
)
|
||||
)
|
||||
|
||||
for accessed_attr in accessed_attrs:
|
||||
try:
|
||||
components_by_accessed_attr[accessed_attr].add(
|
||||
component
|
||||
)
|
||||
except KeyError:
|
||||
components_by_accessed_attr[accessed_attr] = (
|
||||
weakref.WeakSet([component])
|
||||
)
|
||||
|
||||
for key in self._accessed_attachments:
|
||||
self._components_by_accessed_attachment[key].add(component)
|
||||
|
||||
if component in self._changed_properties:
|
||||
raise RuntimeError(
|
||||
f"The `build()` method of the component `{component}`"
|
||||
f" has changed the component's state. This isn't"
|
||||
f" supported, because it would trigger an immediate"
|
||||
f" rebuild, and thus result in an infinite loop. Make"
|
||||
f" sure to perform any changes outside of the `build`"
|
||||
f" function, e.g. in event handlers."
|
||||
)
|
||||
|
||||
# Has this component been built before?
|
||||
component_data = component._build_data_
|
||||
|
||||
# No, this is the first time
|
||||
if component_data is None:
|
||||
# Create the component data and cache it
|
||||
component_data = component._build_data_ = BuildData(
|
||||
build_result,
|
||||
set(), # Set of all children - filled in below
|
||||
key_to_component,
|
||||
)
|
||||
|
||||
# Yes, rescue state. This will:
|
||||
#
|
||||
# - Look for components in the build output which correspond to
|
||||
# components in the previous build output, and transfers state
|
||||
# from the new to the old component ("reconciliation")
|
||||
#
|
||||
# - Replace any references to new, reconciled components in the
|
||||
# build output with the old components instead
|
||||
#
|
||||
# - Add any dirty components from the build output (new, or old
|
||||
# but changed) to the dirty set.
|
||||
#
|
||||
# - Update the component data with the build output resulting
|
||||
# from the operations above
|
||||
else:
|
||||
self._reconcile_tree(
|
||||
component_data, build_result, key_to_component
|
||||
)
|
||||
|
||||
# Reconciliation can change the build result. Make sure
|
||||
# nobody uses `build_result` instead of
|
||||
# `component_data.build_result` from now on.
|
||||
del build_result
|
||||
|
||||
# Remember the previous children of this component
|
||||
old_children_in_build_boundary_for_visited_children[
|
||||
component
|
||||
] = component_data.all_children_in_build_boundary
|
||||
|
||||
# Inject the builder and build generation
|
||||
weak_builder = weakref.ref(component)
|
||||
|
||||
component_data.all_children_in_build_boundary = set(
|
||||
component_data.build_result._iter_direct_and_indirect_child_containing_attributes_(
|
||||
include_self=True,
|
||||
recurse_into_high_level_components=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Fundamental components require no further treatment
|
||||
if isinstance(
|
||||
component, fundamental_component.FundamentalComponent
|
||||
):
|
||||
continue
|
||||
|
||||
# Trigger the `on_populate` event, if it hasn't already. Since the
|
||||
# whole point of this event is to fetch data and modify the
|
||||
# component's state, wait for it to finish if it is synchronous.
|
||||
if not component._on_populate_triggered_:
|
||||
component._on_populate_triggered_ = True
|
||||
|
||||
for handler, _ in component._rio_event_handlers_[
|
||||
rio.event.EventTag.ON_POPULATE
|
||||
]:
|
||||
self._call_event_handler_sync(handler, component)
|
||||
|
||||
# If the event handler made the component dirty again, undo it
|
||||
self._dirty_components.discard(component)
|
||||
|
||||
# Others need to be built
|
||||
global_state.currently_building_component = component
|
||||
global_state.currently_building_session = self
|
||||
|
||||
build_result = utils.safe_build(component.build)
|
||||
|
||||
global_state.currently_building_component = None
|
||||
global_state.currently_building_session = None
|
||||
key_to_component = global_state.key_to_component
|
||||
global_state.key_to_component = {}
|
||||
|
||||
if component in self._dirty_components:
|
||||
raise RuntimeError(
|
||||
f"The `build()` method of the component `{component}` has"
|
||||
f" changed the component's state. This isn't supported,"
|
||||
f" because it would trigger an immediate rebuild, and thus"
|
||||
f" result in an infinite loop. Make sure to perform any"
|
||||
f" changes outside of the `build` function, e.g. in event"
|
||||
f" handlers."
|
||||
)
|
||||
|
||||
# Has this component been built before?
|
||||
component_data = component._build_data_
|
||||
|
||||
# No, this is the first time
|
||||
if component_data is None:
|
||||
# Create the component data and cache it
|
||||
component_data = component._build_data_ = BuildData(
|
||||
build_result,
|
||||
set(), # Set of all children - filled in below
|
||||
key_to_component,
|
||||
)
|
||||
|
||||
# Yes, rescue state. This will:
|
||||
#
|
||||
# - Look for components in the build output which correspond to
|
||||
# components in the previous build output, and transfers state
|
||||
# from the new to the old component ("reconciliation")
|
||||
#
|
||||
# - Replace any references to new, reconciled components in the
|
||||
# build output with the old components instead
|
||||
#
|
||||
# - Add any dirty components from the build output (new, or old but
|
||||
# changed) to the dirty set.
|
||||
#
|
||||
# - Update the component data with the build output resulting from
|
||||
# the operations above
|
||||
else:
|
||||
self._reconcile_tree(
|
||||
component_data, build_result, key_to_component
|
||||
)
|
||||
|
||||
# Reconciliation can change the build result. Make sure nobody
|
||||
# uses `build_result` instead of `component_data.build_result`
|
||||
# from now on.
|
||||
del build_result
|
||||
|
||||
# Remember the previous children of this component
|
||||
old_children_in_build_boundary_for_visited_children[component] = (
|
||||
component_data.all_children_in_build_boundary
|
||||
)
|
||||
|
||||
# Inject the builder and build generation
|
||||
weak_builder = weakref.ref(component)
|
||||
|
||||
component_data.all_children_in_build_boundary = set(
|
||||
component_data.build_result._iter_direct_and_indirect_child_containing_attributes_(
|
||||
include_self=True,
|
||||
recurse_into_high_level_components=False,
|
||||
)
|
||||
)
|
||||
for child in component_data.all_children_in_build_boundary:
|
||||
child._weak_builder_ = weak_builder
|
||||
for child in component_data.all_children_in_build_boundary:
|
||||
child._weak_builder_ = weak_builder
|
||||
|
||||
# Determine which components are alive, to avoid sending references to
|
||||
# dead components to the frontend.
|
||||
@@ -1350,6 +1478,7 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
|
||||
return (
|
||||
visited_and_live_components,
|
||||
properties_to_serialize,
|
||||
mounted_components,
|
||||
unmounted_components,
|
||||
)
|
||||
@@ -1372,11 +1501,12 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
# Clear the dict of crashed build functions
|
||||
self._crashed_build_functions.clear()
|
||||
|
||||
while self._dirty_components:
|
||||
while True:
|
||||
# Refresh and get a set of all components which have been
|
||||
# visited
|
||||
(
|
||||
visited_components,
|
||||
properties_to_serialize,
|
||||
mounted_components,
|
||||
unmounted_components,
|
||||
) = self._refresh_sync()
|
||||
@@ -1388,7 +1518,7 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
# Serialize all components which have been visited
|
||||
delta_states: dict[int, JsonDoc] = {
|
||||
component._id_: serialization.serialize_and_host_component(
|
||||
component
|
||||
component, properties_to_serialize[component]
|
||||
)
|
||||
for component in visited_components
|
||||
}
|
||||
@@ -1483,7 +1613,12 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
) in self._high_level_root_component._iter_component_tree_():
|
||||
visited_components.add(component)
|
||||
delta_states[component._id_] = (
|
||||
serialization.serialize_and_host_component(component)
|
||||
serialization.serialize_and_host_component(
|
||||
component,
|
||||
serialization.get_attribute_serializers(
|
||||
type(component)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await self._update_component_states(
|
||||
@@ -1551,11 +1686,8 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
added_children_by_builder[builder].update(added_children)
|
||||
removed_children_by_builder[builder].update(removed_children)
|
||||
|
||||
# Performance optimization: Since the new component has just been
|
||||
# reconciled into the old one, it cannot possibly still be part of
|
||||
# the component tree. It is thus safe to remove from the set of dirty
|
||||
# components to prevent a pointless rebuild.
|
||||
self._dirty_components.discard(new_component)
|
||||
# Performance optimization: Avoid building the new components
|
||||
self._newly_created_components.discard(new_component)
|
||||
|
||||
# Now that we have collected all added and removed children, update the
|
||||
# builder's `all_children_in_build_boundary` set
|
||||
@@ -1793,20 +1925,23 @@ window.location.href = {json.dumps(str(active_page_url))};
|
||||
except Exception:
|
||||
return old is new
|
||||
|
||||
# Determine which properties will be taken from the new component
|
||||
# Determine which properties have changed
|
||||
changed_properties = set[str]()
|
||||
|
||||
for prop_name in overridden_values:
|
||||
old_value = getattr(old_component, prop_name)
|
||||
new_value = getattr(new_component, prop_name)
|
||||
|
||||
if not values_equal(old_value, new_value):
|
||||
self._dirty_components.add(old_component)
|
||||
break
|
||||
changed_properties.add(prop_name)
|
||||
|
||||
self._changed_properties[old_component].update(changed_properties)
|
||||
|
||||
# Now combine the old and new dictionaries
|
||||
#
|
||||
# Notice that the component's `_weak_builder_` is always preserved. So even
|
||||
# components whose position in the tree has changed still have the correct
|
||||
# builder set.
|
||||
# Notice that the component's `_weak_builder_` is always preserved. So
|
||||
# even components whose position in the tree has changed still have the
|
||||
# correct builder set.
|
||||
old_component_dict.update(overridden_values)
|
||||
|
||||
# Update the metadata
|
||||
|
||||
@@ -22,6 +22,9 @@ class SessionAttachments:
|
||||
return typ in self._attachments
|
||||
|
||||
def __getitem__(self, typ: type[T]) -> T:
|
||||
if self._session._record_accessed_observables:
|
||||
self._session._accessed_attachments.add(typ)
|
||||
|
||||
try:
|
||||
return self._attachments[typ] # type: ignore
|
||||
except KeyError:
|
||||
@@ -30,6 +33,8 @@ class SessionAttachments:
|
||||
def _add(self, value: object, synchronize: bool) -> None:
|
||||
cls = type(value)
|
||||
|
||||
self._session._changed_attachments.add(cls)
|
||||
|
||||
# If the value isn't a UserSettings instance, just assign it and we're
|
||||
# done
|
||||
if not isinstance(value, user_settings_module.UserSettings):
|
||||
@@ -72,6 +77,8 @@ class SessionAttachments:
|
||||
# Remove the attachment, propagating any `KeyError`
|
||||
old_value = self._attachments.pop(typ)
|
||||
|
||||
self._session._changed_attachments.add(typ)
|
||||
|
||||
# User settings need special care
|
||||
if not isinstance(old_value, user_settings_module.UserSettings):
|
||||
return
|
||||
|
||||
@@ -71,6 +71,9 @@ class StateProperty:
|
||||
if instance is None:
|
||||
return self
|
||||
|
||||
if instance._session_._record_accessed_observables:
|
||||
instance._session_._accessed_properties[instance].add(self.name)
|
||||
|
||||
# Otherwise get the value assigned to the property in the component
|
||||
# instance
|
||||
try:
|
||||
@@ -92,8 +95,6 @@ class StateProperty:
|
||||
f"Cannot assign to readonly property {cls_name}.{self.name}"
|
||||
)
|
||||
|
||||
instance._properties_assigned_after_creation_.add(self.name)
|
||||
|
||||
# Look up the stored value
|
||||
instance_vars = vars(instance)
|
||||
try:
|
||||
@@ -121,7 +122,8 @@ class StateProperty:
|
||||
# Otherwise set the value directly and mark the component as dirty
|
||||
instance_vars[self.name] = value
|
||||
|
||||
instance._session_._dirty_components.add(instance)
|
||||
instance._session_._changed_properties[instance].add(self.name)
|
||||
instance._properties_assigned_after_creation_.add(self.name)
|
||||
|
||||
def _create_attribute_binding(
|
||||
self,
|
||||
@@ -209,8 +211,13 @@ class AttributeBinding:
|
||||
owning_component = cur.owning_component_weak()
|
||||
|
||||
if owning_component is not None:
|
||||
owning_component._session_._dirty_components.add(
|
||||
prop_name = cur.owning_property.name # type: ignore (it's incorrectly treating `owning_property` as a descriptor)
|
||||
|
||||
owning_component._session_._changed_properties[
|
||||
owning_component
|
||||
].add(prop_name)
|
||||
owning_component._properties_assigned_after_creation_.add(
|
||||
prop_name
|
||||
)
|
||||
|
||||
to_do.extend(cur.children)
|
||||
|
||||
@@ -2,7 +2,6 @@ import abc
|
||||
import asyncio
|
||||
import typing as t
|
||||
|
||||
import ordered_set
|
||||
import typing_extensions as te
|
||||
from uniserde import JsonDoc
|
||||
|
||||
@@ -28,7 +27,7 @@ class BaseClient(abc.ABC):
|
||||
running_in_window: bool = False,
|
||||
user_settings: JsonDoc = {},
|
||||
active_url: str = "/",
|
||||
use_ordered_dirty_set: bool = False,
|
||||
debug_mode: bool = False,
|
||||
): ...
|
||||
|
||||
@t.overload
|
||||
@@ -41,13 +40,9 @@ class BaseClient(abc.ABC):
|
||||
running_in_window: bool = False,
|
||||
user_settings: JsonDoc = {},
|
||||
active_url: str = "/",
|
||||
use_ordered_dirty_set: bool = False,
|
||||
debug_mode: bool = False,
|
||||
): ...
|
||||
|
||||
# Note about `use_ordered_dirty_set`: It's tempting to make it `True` per
|
||||
# default so that the unit tests are deterministic, but there have been
|
||||
# plenty of tests in the past that only failed *sometimes*, and without that
|
||||
# randomness we wouldn't have found the bug at all.
|
||||
def __init__( # type: ignore
|
||||
self,
|
||||
app_or_build: rio.App | t.Callable[[], rio.Component] | None = None,
|
||||
@@ -60,7 +55,6 @@ class BaseClient(abc.ABC):
|
||||
user_settings: JsonDoc = {},
|
||||
active_url: str = "/",
|
||||
debug_mode: bool = False,
|
||||
use_ordered_dirty_set: bool = False,
|
||||
):
|
||||
if app is None:
|
||||
if isinstance(app_or_build, rio.App):
|
||||
@@ -83,7 +77,6 @@ class BaseClient(abc.ABC):
|
||||
self._active_url = active_url
|
||||
self._running_in_window = running_in_window
|
||||
self._debug_mode = debug_mode
|
||||
self._use_ordered_dirty_set = use_ordered_dirty_set
|
||||
|
||||
self._recorder_transport = MessageRecorderTransport(
|
||||
process_sent_message=self._process_sent_message
|
||||
@@ -119,11 +112,6 @@ class BaseClient(abc.ABC):
|
||||
|
||||
self._session = await self._create_session()
|
||||
|
||||
if self._use_ordered_dirty_set:
|
||||
self._session._dirty_components = ordered_set.OrderedSet(
|
||||
self._session._dirty_components
|
||||
) # type: ignore
|
||||
|
||||
await self._first_refresh_completed.wait()
|
||||
|
||||
return self
|
||||
@@ -140,7 +128,7 @@ class BaseClient(abc.ABC):
|
||||
|
||||
@property
|
||||
def _dirty_components(self) -> set[rio.Component]:
|
||||
return set(self.session._dirty_components)
|
||||
return self.session._collect_components_to_build()
|
||||
|
||||
@property
|
||||
def _last_updated_components(self) -> set[rio.Component]:
|
||||
|
||||
54
rio/utils.py
54
rio/utils.py
@@ -9,6 +9,7 @@ import re
|
||||
import secrets
|
||||
import socket
|
||||
import typing as t
|
||||
import weakref
|
||||
from io import BytesIO, StringIO
|
||||
from pathlib import Path
|
||||
|
||||
@@ -146,6 +147,30 @@ def secure_string_hash(*values: str, hash_length: int = 32) -> str:
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
K = t.TypeVar("K")
|
||||
V = t.TypeVar("V")
|
||||
|
||||
|
||||
class WeakKeyDefaultDict(weakref.WeakKeyDictionary[K, V]):
|
||||
def __init__(self, default_factory: t.Callable[[], V]):
|
||||
super().__init__()
|
||||
|
||||
self.default_factory = default_factory
|
||||
|
||||
def __getitem__(self, key: K) -> V:
|
||||
try:
|
||||
return super().__getitem__(key)
|
||||
except KeyError:
|
||||
value = self.default_factory()
|
||||
self[key] = value
|
||||
return value
|
||||
|
||||
def __repr__(self):
|
||||
cls_name = type(self).__name__
|
||||
contents = dict(self)
|
||||
return f"<{cls_name} {contents!r}>"
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class FileInfo:
|
||||
"""
|
||||
@@ -583,35 +608,6 @@ def safe_build(
|
||||
return placeholder_component
|
||||
|
||||
|
||||
# def normalize_url(url: str | rio.URL) -> rio.URL:
|
||||
# """
|
||||
# Returns a normalized version of the given URL.
|
||||
|
||||
# This returns a new URL instance which is identical to the given URL, but
|
||||
# with the following guarantees:
|
||||
|
||||
# - The protocol, host and path are lowercase
|
||||
# - The URL has no trailing slashes
|
||||
|
||||
# Query parameters and fragments are considered case-sensitive, and are not
|
||||
# modified.
|
||||
# """
|
||||
# url = rio.URL(url)
|
||||
|
||||
# if url.scheme:
|
||||
# url = url.with_scheme(url.scheme.lower())
|
||||
|
||||
# if url.host is not None:
|
||||
# url = url.with_host(url.host.lower())
|
||||
|
||||
# if url.path != "/":
|
||||
# # Doing `url.with_path(url.path)` erases the query parameters.
|
||||
# # Unbelievable.
|
||||
# url = url.with_path(url.path.rstrip("/").lower()).with_query(url.query)
|
||||
|
||||
# return url
|
||||
|
||||
|
||||
def is_python_script(path: Path) -> bool:
|
||||
"""
|
||||
Guesses whether the path points to a Python script, based on the path's file
|
||||
|
||||
@@ -19,8 +19,9 @@ class Grandparent(rio.Component):
|
||||
|
||||
|
||||
async def test_bindings_arent_created_too_early() -> None:
|
||||
# There was a time when attribute bindings were created in `Component.__init__`,
|
||||
# thus skipping any properties that were only assigned later.
|
||||
# There was a time when attribute bindings were created in
|
||||
# `Component.__init__`, thus skipping any properties that were only assigned
|
||||
# later.
|
||||
class IHaveACustomInit(rio.Component):
|
||||
text: str
|
||||
|
||||
@@ -85,29 +86,58 @@ async def test_init_receives_attribute_bindings_as_input() -> None:
|
||||
async def test_binding_assignment_on_child() -> None:
|
||||
async with rio.testing.DummyClient(Parent) as test_client:
|
||||
root_component = test_client.get_component(Parent)
|
||||
text_component = test_client._get_build_output(root_component, rio.Text)
|
||||
text_component = test_client.get_component(rio.Text)
|
||||
|
||||
assert not test_client._dirty_components
|
||||
|
||||
text_component.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
text_component,
|
||||
}
|
||||
assert root_component.text == "Hello"
|
||||
# Note: The Parent isn't dirty because its `build` never accesses
|
||||
# `self.text`. It only uses `text` as an attribute binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert text_component.text == "Hello"
|
||||
assert root_component.text == "Hello"
|
||||
|
||||
|
||||
async def test_binding_assignment_on_parent() -> None:
|
||||
async with rio.testing.DummyClient(Parent) as test_client:
|
||||
root_component = test_client.get_component(Parent)
|
||||
text_component = test_client._get_build_output(root_component)
|
||||
text_component = test_client.get_component(rio.Text)
|
||||
|
||||
assert not test_client._dirty_components
|
||||
|
||||
root_component.text = "Hello"
|
||||
|
||||
# Note: The Parent isn't dirty because its `build` never accesses
|
||||
# `self.text`. It only uses `text` as an attribute binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert text_component.text == "Hello"
|
||||
assert root_component.text == "Hello"
|
||||
|
||||
|
||||
async def test_using_binding_and_value() -> None:
|
||||
# The `Parent` class used in the previous tests only uses `text` as a
|
||||
# binding, not as a value. This test ensures that the parent is properly
|
||||
# rebuilt if it depends on the value.
|
||||
class Hybrid(rio.Component):
|
||||
text: str = ""
|
||||
|
||||
def build(self) -> rio.Component:
|
||||
return rio.Column(
|
||||
rio.Text(self.bind().text),
|
||||
rio.Markdown(self.text),
|
||||
)
|
||||
|
||||
async with rio.testing.DummyClient(Hybrid) as test_client:
|
||||
root_component = test_client.get_component(Hybrid)
|
||||
text_component = test_client.get_component(rio.Text)
|
||||
|
||||
assert not test_client._dirty_components
|
||||
|
||||
text_component.text = "Hello"
|
||||
|
||||
# Note: The Markdown component isn't dirty (yet) because its parent
|
||||
# hasn't been rebuilt yet
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
text_component,
|
||||
@@ -137,11 +167,9 @@ async def test_binding_assignment_on_sibling() -> None:
|
||||
|
||||
text1.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
text1,
|
||||
text2,
|
||||
}
|
||||
# Note: The root_component isn't dirty because its `build` never
|
||||
# accesses `self.text`. It only uses `text` as an attribute binding.
|
||||
assert test_client._dirty_components == {text1, text2}
|
||||
assert root_component.text == "Hello"
|
||||
assert text1.text == "Hello"
|
||||
assert text2.text == "Hello"
|
||||
@@ -157,11 +185,10 @@ async def test_binding_assignment_on_grandchild() -> None:
|
||||
|
||||
text_component.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
parent,
|
||||
text_component,
|
||||
}
|
||||
# Note: The Parent and GrandParent aren't dirty because their `build`
|
||||
# never accesses `self.text`. It only uses `text` as an attribute
|
||||
# binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert root_component.text == "Hello"
|
||||
assert parent.text == "Hello"
|
||||
assert text_component.text == "Hello"
|
||||
@@ -177,11 +204,10 @@ async def test_binding_assignment_on_middle() -> None:
|
||||
|
||||
parent.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
parent,
|
||||
text_component,
|
||||
}
|
||||
# Note: The Parent and GrandParent aren't dirty because their `build`
|
||||
# never accesses `self.text`. It only uses `text` as an attribute
|
||||
# binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert root_component.text == "Hello"
|
||||
assert parent.text == "Hello"
|
||||
assert text_component.text == "Hello"
|
||||
@@ -195,14 +221,13 @@ async def test_binding_assignment_on_child_after_reconciliation() -> None:
|
||||
assert not test_client._dirty_components
|
||||
|
||||
# Rebuild the root component, which reconciles the child
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
text_component.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
text_component,
|
||||
}
|
||||
# Note: The Parent isn't dirty because its `build` never accesses
|
||||
# `self.text`. It only uses `text` as an attribute binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert root_component.text == "Hello"
|
||||
assert text_component.text == "Hello"
|
||||
|
||||
@@ -215,14 +240,13 @@ async def test_binding_assignment_on_parent_after_reconciliation() -> None:
|
||||
assert not test_client._dirty_components
|
||||
|
||||
# Rebuild the root component, which reconciles the child
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
root_component.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
text_component,
|
||||
}
|
||||
# Note: The Parent isn't dirty because its `build` never accesses
|
||||
# `self.text`. It only uses `text` as an attribute binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert root_component.text == "Hello"
|
||||
assert text_component.text == "Hello"
|
||||
|
||||
@@ -244,15 +268,13 @@ async def test_binding_assignment_on_sibling_after_reconciliation() -> None:
|
||||
assert not test_client._dirty_components
|
||||
|
||||
# Rebuild the root component, which reconciles the children
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
text1.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
text1,
|
||||
text2,
|
||||
}
|
||||
# Note: The Root isn't dirty because its `build` never accesses
|
||||
# `self.text`. It only uses `text` as an attribute binding.
|
||||
assert test_client._dirty_components == {text1, text2}
|
||||
assert root_component.text == "Hello"
|
||||
assert text1.text == "Hello"
|
||||
assert text2.text == "Hello"
|
||||
@@ -267,15 +289,14 @@ async def test_binding_assignment_on_grandchild_after_reconciliation() -> None:
|
||||
assert not test_client._dirty_components
|
||||
|
||||
# Rebuild the root component, which reconciles the child
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
text_component.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
parent,
|
||||
text_component,
|
||||
}
|
||||
# Note: The Parent and GrandParent aren't dirty because their `build`
|
||||
# never accesses `self.text`. It only uses `text` as an attribute
|
||||
# binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert root_component.text == "Hello"
|
||||
assert parent.text == "Hello"
|
||||
assert text_component.text == "Hello"
|
||||
@@ -290,15 +311,14 @@ async def test_binding_assignment_on_middle_after_reconciliation() -> None:
|
||||
assert not test_client._dirty_components
|
||||
|
||||
# Rebuild the root component, which reconciles the child
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
parent.text = "Hello"
|
||||
|
||||
assert test_client._dirty_components == {
|
||||
root_component,
|
||||
parent,
|
||||
text_component,
|
||||
}
|
||||
# Note: The Parent and GrandParent aren't dirty because their `build`
|
||||
# never accesses `self.text`. It only uses `text` as an attribute
|
||||
# binding.
|
||||
assert test_client._dirty_components == {text_component}
|
||||
assert root_component.text == "Hello"
|
||||
assert parent.text == "Hello"
|
||||
assert text_component.text == "Hello"
|
||||
|
||||
@@ -8,7 +8,7 @@ async def test_dropdowns_work_in_dev_tools() -> None:
|
||||
# Dropdowns (and other popups) have often been broken in the dev tools, due
|
||||
# to z-index issues and other reasons. This test makes sure that they work.
|
||||
|
||||
async with BrowserClient(rio.Spacer) as client:
|
||||
async with BrowserClient(rio.Spacer, debug_mode=True) as client:
|
||||
# Click the 2nd entry in the sidebar, which is the "Icons" tab
|
||||
await client.execute_js(
|
||||
"document.querySelector('.rio-switcher-bar-option:nth-child(2) .rio-switcher-bar-icon').click()",
|
||||
|
||||
@@ -57,9 +57,7 @@ async def test_reconcile_instance_with_itself() -> None:
|
||||
def build() -> rio.Component:
|
||||
return Container(rio.Text("foo"))
|
||||
|
||||
async with rio.testing.DummyClient(
|
||||
build, use_ordered_dirty_set=True
|
||||
) as test_client:
|
||||
async with rio.testing.DummyClient(build) as test_client:
|
||||
container = test_client.get_component(Container)
|
||||
child = test_client.get_component(rio.Text)
|
||||
|
||||
@@ -70,10 +68,7 @@ async def test_reconcile_instance_with_itself() -> None:
|
||||
# In order for the bug to occur, the parent has to be rebuilt before the
|
||||
# child
|
||||
assert test_client._session is not None
|
||||
assert list(test_client._session._dirty_components) == [
|
||||
child,
|
||||
container,
|
||||
]
|
||||
assert test_client._dirty_components == {child, container}
|
||||
await test_client.refresh()
|
||||
|
||||
assert test_client._last_updated_components == {child, container}
|
||||
@@ -89,7 +84,7 @@ async def test_reconcile_same_component_instance():
|
||||
test_client._received_messages.clear()
|
||||
|
||||
root_component = test_client.get_component(rio.Container)
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
# Nothing changed, so there's no need to send any data to JS. But in
|
||||
# order to know that nothing changed, the framework would have to track
|
||||
@@ -125,7 +120,7 @@ async def test_reconcile_unusual_types():
|
||||
root_component = test_client.get_component(Container)
|
||||
|
||||
# As long as this doesn't crash, it's fine
|
||||
await root_component._force_refresh()
|
||||
await root_component._force_refresh_()
|
||||
|
||||
|
||||
async def test_reconcile_by_key():
|
||||
|
||||
@@ -47,9 +47,7 @@ async def test_rebuild_component_with_dead_parent() -> None:
|
||||
def build() -> rio.Component:
|
||||
return ChildUnmounter(ComponentWithState("Hello"))
|
||||
|
||||
async with rio.testing.DummyClient(
|
||||
build, use_ordered_dirty_set=True
|
||||
) as test_client:
|
||||
async with rio.testing.DummyClient(build) as test_client:
|
||||
# Change the component's state, but also remove it from the component
|
||||
# tree
|
||||
unmounter = test_client.get_component(ChildUnmounter)
|
||||
|
||||
Reference in New Issue
Block a user