track dependencies of build functions

This commit is contained in:
Aran-Fey
2025-04-04 19:44:49 +02:00
parent 571d41420d
commit 44aeb9b8f1
17 changed files with 476 additions and 314 deletions

View File

@@ -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

View File

@@ -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]:

View File

@@ -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",
)
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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]:

View File

@@ -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

View 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"

View File

@@ -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()",

View File

@@ -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():

View File

@@ -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)