From 300a04469aa3013a8a0d92d6730e06369c8d92f6 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 7 May 2026 19:21:59 +0500 Subject: [PATCH 1/3] refactor: freeze components after create and use copy_with for mutations Components are now immutable after construction (children stored as tuples, __setattr__ blocks writes outside a small cache allowlist). Compile-time edits go through a new copy_with() helper instead of mutating shared instances, replacing the PageContext.own() page-local clone mechanism so components can be safely reused across pages without deep copies. --- .../src/reflex_base/components/component.py | 148 +++++++++++++----- .../reflex_base/components/memoize_helpers.py | 24 +-- .../reflex_base/components/tags/iter_tag.py | 2 +- .../src/reflex_base/plugins/compiler.py | 49 +----- .../src/reflex_components_core/base/bare.py | 51 ++++-- .../src/reflex_components_core/core/banner.py | 3 +- .../reflex_components_core/core/debounce.py | 37 +++-- .../reflex_components_core/core/foreach.py | 5 +- .../src/reflex_components_core/core/upload.py | 32 ++-- .../core/window_events.py | 17 +- .../el/elements/forms.py | 9 +- .../src/reflex_components_gridjs/datatable.py | 32 ++-- .../reflex_components_radix/themes/base.py | 8 +- .../themes/components/icon_button.py | 5 +- .../themes/components/text_area.py | 5 +- pyi_hashes.json | 2 +- reflex/app.py | 26 ++- reflex/compiler/compiler.py | 4 +- reflex/compiler/plugins/builtin.py | 42 ++--- reflex/compiler/plugins/memoize.py | 3 +- reflex/compiler/utils.py | 89 ++++++----- reflex/experimental/memo.py | 12 +- reflex/state.py | 3 +- tests/units/compiler/test_memoize_plugin.py | 18 ++- tests/units/compiler/test_plugins.py | 9 +- tests/units/components/core/test_foreach.py | 2 +- tests/units/components/test_component.py | 120 +++++++++++++- tests/units/experimental/test_memo.py | 2 +- 28 files changed, 473 insertions(+), 286 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 2605e2f2315..0a0db0f0df0 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast, get_args, get_origin from rich.markup import escape -from typing_extensions import dataclass_transform +from typing_extensions import Self, dataclass_transform from reflex_base import constants from reflex_base.breakpoints import Breakpoints @@ -266,9 +266,20 @@ class BaseComponent(metaclass=BaseComponentMeta): This is something that can be rendered as a Component via the Reflex compiler. """ - children: list[BaseComponent] = field( + _frozen: ClassVar[bool] = False + + # Render-path caches; allowed to be written even on frozen instances. + _CACHE_ATTRS: ClassVar[frozenset[str]] = frozenset({ + "_cached_render_result", + "_vars_cache", + "_imports_cache", + "_hooks_internal_cache", + "_get_component_prop_property", + }) + + children: tuple[BaseComponent, ...] = field( doc="The children nested within the component.", - default_factory=list, + default_factory=tuple, is_javascript_property=False, ) @@ -293,24 +304,73 @@ def __init__( Args: **kwargs: The kwargs to pass to the component. """ + if "children" in kwargs: + kwargs["children"] = tuple(kwargs["children"]) for key, value in kwargs.items(): setattr(self, key, value) for name, value in self.get_fields().items(): if name not in kwargs: setattr(self, name, value.default_value()) + def __setattr__(self, key: str, value: Any) -> None: + """Block writes to frozen components, except for cache attributes. + + Args: + key: The attribute name. + value: The attribute value. + + Raises: + AttributeError: If the component is frozen and the attribute is not a cache. + """ + if self.__dict__.get("_frozen", False) and key not in type(self)._CACHE_ATTRS: + msg = ( + f"Cannot set {key!r} on frozen {type(self).__name__}; " + "use copy_with() to create a modified copy." + ) + raise AttributeError(msg) + super().__setattr__(key, value) + + def _freeze(self) -> None: + """Mark this component as frozen. + + Subsequent attribute writes outside the cache allowlist will raise. + """ + object.__setattr__(self, "_frozen", True) + + def copy_with(self, **updates: Any) -> Self: + """Return a frozen shallow copy with updated fields. + + Bypasses ``__setattr__`` for speed and to skip the freeze guard. + Render-path caches are dropped because they may depend on the fields + being replaced. + + Args: + **updates: Field values to override on the copy. + + Returns: + A new frozen instance with the requested updates applied. + """ + new = self.__class__.__new__(self.__class__) + d = vars(new) + d.update(vars(self)) + for cache_attr in type(self)._CACHE_ATTRS: + d.pop(cache_attr, None) + if "children" in updates: + updates["children"] = tuple(updates["children"]) + d.update(updates) + d["_frozen"] = True + return new + def set(self, **kwargs): - """Set the component props. + """Set the component props, returning a new frozen instance. Args: **kwargs: The kwargs to set. Returns: - The component with the updated props. + A new component with the updated props. """ - for key, value in kwargs.items(): - setattr(self, key, value) - return self + return self.copy_with(**kwargs) def __copy__(self) -> BaseComponent: """Return a shallow copy suitable for compile-time mutation. @@ -327,13 +387,7 @@ def __copy__(self) -> BaseComponent: new = self.__class__.__new__(self.__class__) new_dict = vars(new) new_dict.update(vars(self)) - for attr in ( - "_cached_render_result", - "_vars_cache", - "_imports_cache", - "_hooks_internal_cache", - "_get_component_prop_property", - ): + for attr in type(self)._CACHE_ATTRS: new_dict.pop(attr, None) return new @@ -1223,9 +1277,11 @@ def _create(cls: type[T], children: Sequence[BaseComponent], **props: Any) -> T: Returns: The component. """ + children_tuple = tuple(children) comp = cls.__new__(cls) - super(Component, comp).__init__(id=props.get("id"), children=list(children)) - comp._post_init(children=list(children), **props) + super(Component, comp).__init__(id=props.get("id"), children=children_tuple) + comp._post_init(children=children_tuple, **props) + comp._freeze() return comp @classmethod @@ -1241,10 +1297,12 @@ def _unsafe_create( Returns: The component. """ + children_tuple = tuple(children) comp = cls.__new__(cls) - super(Component, comp).__init__(id=props.get("id"), children=list(children)) + super(Component, comp).__init__(id=props.get("id"), children=children_tuple) for prop, value in props.items(): setattr(comp, prop, value) + comp._freeze() return comp def add_style(self) -> dict[str, Any] | None: @@ -1311,40 +1369,47 @@ def _add_style_recursive( theme: The theme to apply. (for retro-compatibility with deprecated _apply_theme API) Returns: - The component with the additional style. + A component with the additional style; ``self`` if nothing changed. Raises: UserWarning: If `_add_style` has been overridden. """ - # 1. Default style from `_add_style`/`add_style`. if type(self)._add_style != Component._add_style: msg = "Do not override _add_style directly. Use add_style instead." raise UserWarning(msg) - new_style = self._add_style() - style_vars = [new_style._var_data] - # 2. User-defined style from `App.style`. + style_addition = self._add_style() component_style = self._get_component_style(style) - if component_style: - new_style.update(component_style) - style_vars.append(component_style._var_data) - - # 4. style dict and css props passed to the component instance. - new_style.update(self.style) - style_vars.append(self.style._var_data) - - new_style._var_data = VarData.merge(*style_vars) - - # Assign the new style - self.style = new_style + has_style_change = bool(style_addition) or bool(component_style) - # Recursively add style to the children. - for child in self.children: - # Skip non-Component children. + new_children: list | None = None + for i, child in enumerate(self.children): if not isinstance(child, Component): continue - child._add_style_recursive(style, theme) - return self + updated = child._add_style_recursive(style, theme) + if updated is child: + continue + if new_children is None: + new_children = list(self.children) + new_children[i] = updated + + if not has_style_change and new_children is None: + return self + + updates: dict[str, Any] = {} + if has_style_change: + new_style = style_addition + style_vars = [new_style._var_data] + if component_style: + new_style.update(component_style) + style_vars.append(component_style._var_data) + new_style.update(self.style) + style_vars.append(self.style._var_data) + new_style._var_data = VarData.merge(*style_vars) + updates["style"] = new_style + if new_children is not None: + updates["children"] = tuple(new_children) + return self.copy_with(**updates) def _get_style(self) -> dict: """Get the style for the component. @@ -2342,8 +2407,7 @@ def get_component(self) -> Component: except Exception: style = {} - component._add_style_recursive(style) - return component + return component._add_style_recursive(style) def _get_all_app_wrap_components( self, *, ignore_ids: set[int] | None = None diff --git a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py index 5c8f714465a..86286b16089 100644 --- a/packages/reflex-base/src/reflex_base/components/memoize_helpers.py +++ b/packages/reflex-base/src/reflex_base/components/memoize_helpers.py @@ -154,27 +154,27 @@ def fix_event_triggers_for_memo( """Return a component whose event triggers reference memoized ``useCallback``s. Replaces each (non-lifecycle) event-trigger value with a ``Var`` naming a - memoized ``useCallback`` wrapper. The original is never mutated — a - page-local clone is taken via ``page_context.own`` on first write. + memoized ``useCallback`` wrapper. The original is never mutated — a frozen + copy with the rewritten triggers is returned via ``copy_with``. Args: component: The component whose event triggers to memoize. - page_context: The active page context, used to obtain a page-local - clone before rewriting ``event_triggers``. + page_context: The active page context (unused; retained for API + compatibility with downstream callers). Returns: - Either ``component`` (when nothing needed rewriting) or a page-local - clone with the rewritten ``event_triggers``. + Either ``component`` (when nothing needed rewriting) or a new frozen + copy with the rewritten ``event_triggers``. """ memo_event_triggers = tuple(get_memoized_event_triggers(component).items()) if not memo_event_triggers: return component - owned = page_context.own(component) - owned.event_triggers = { - **component.event_triggers, - **dict(memo_event_triggers), - } - return owned + return component.copy_with( + event_triggers={ + **component.event_triggers, + **dict(memo_event_triggers), + } + ) def is_snapshot_boundary(component: Component) -> bool: diff --git a/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py b/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py index f5391905ea3..546402a4454 100644 --- a/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py +++ b/packages/reflex-base/src/reflex_base/components/tags/iter_tag.py @@ -112,6 +112,6 @@ def render_component(self) -> Component: # Set the component key. if component.key is None: - component.key = index + component = component.copy_with(key=index) return component diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index ecb55a03d92..65840ae01e4 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -2,13 +2,12 @@ from __future__ import annotations -import copy import dataclasses import inspect from collections.abc import Callable, Sequence from contextvars import ContextVar, Token from types import TracebackType -from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, TypeVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, cast from typing_extensions import Self @@ -32,9 +31,6 @@ ) -_BaseComponentT = TypeVar("_BaseComponentT", bound=BaseComponent) - - class PageDefinition(Protocol): """Protocol for page-like objects compiled by :class:`CompileContext`.""" @@ -374,8 +370,7 @@ def visit( updated_children = list(children[:index]) updated_children.append(compiled_child) if updated_children is not None: - current_comp = page_context.own(current_comp) - current_comp.children = updated_children + current_comp = current_comp.copy_with(children=tuple(updated_children)) if isinstance(current_comp, Component): for prop_component in current_comp._get_components_in_props(): @@ -437,8 +432,7 @@ def visit( updated_children = list(children[:index]) updated_children.append(compiled_child) if updated_children is not None: - current_comp = page_context.own(current_comp) - current_comp.children = updated_children + current_comp = current_comp.copy_with(children=tuple(updated_children)) if isinstance(current_comp, Component): for prop_component in current_comp._get_components_in_props(): @@ -549,8 +543,9 @@ def visit( if len(compiled_children) != len(current) or any( a is not b for a, b in zip(compiled_children, current, strict=True) ): - compiled_component = page_context.own(compiled_component) - compiled_component.children = list(compiled_children) + compiled_component = compiled_component.copy_with( + children=tuple(compiled_children) + ) return compiled_component return visit( @@ -695,38 +690,6 @@ class PageContext(BaseContext): # the matching ``leave_component``. Non-empty iff we are inside such a # subtree. memoize_suppressor_stack: list[int] = dataclasses.field(default_factory=list) - # Maps both the user-owned original's ``id()`` and the clone's ``id()`` to - # the page-local clone. Lets the walker and plugins rebind children, style, - # or event_triggers on a page-local copy without mutating a user-owned - # instance that may be referenced from another route. - _owned: dict[int, BaseComponent] = dataclasses.field(default_factory=dict) - # Strong references to originals keyed by ``id()`` above. Without these, - # an original that is only reachable through ``_owned``'s int key can be - # garbage collected, and Python may recycle its ``id()`` for a fresh - # component, causing ``own()`` to hand back the wrong clone. - _owned_refs: list[BaseComponent] = dataclasses.field(default_factory=list) - - def own(self, comp: _BaseComponentT) -> _BaseComponentT: - """Return a page-local copy of ``comp``, cloning on first encounter. - - Repeated calls with the same original return the same clone, so - mutations from several plugins accumulate on one instance. - - Args: - comp: The component the caller is about to mutate. - - Returns: - A component the caller may freely mutate without touching any - user-owned instance. - """ - existing = self._owned.get(id(comp)) - if existing is not None: - return cast("_BaseComponentT", existing) - new = copy.copy(comp) - self._owned[id(comp)] = new - self._owned[id(new)] = new - self._owned_refs.append(comp) - return new def merged_imports(self, *, collapse: bool = False) -> ParsedImportDict: """Return the imports accumulated for this page. diff --git a/packages/reflex-components-core/src/reflex_components_core/base/bare.py b/packages/reflex-components-core/src/reflex_components_core/base/bare.py index dfba2cccbe5..f550436f797 100644 --- a/packages/reflex-components-core/src/reflex_components_core/base/bare.py +++ b/packages/reflex-components-core/src/reflex_components_core/base/bare.py @@ -2,6 +2,7 @@ from __future__ import annotations +import dataclasses from collections.abc import Iterator, Sequence from typing import Any @@ -13,7 +14,7 @@ from reflex_base.utils.decorator import once from reflex_base.utils.imports import ParsedImportDict from reflex_base.vars import BooleanVar, ObjectVar, Var -from reflex_base.vars.base import GLOBAL_CACHE, VarData +from reflex_base.vars.base import VarData from reflex_base.vars.sequence import LiteralStringVar @@ -215,22 +216,44 @@ def _add_style_recursive( theme: The theme to add. Returns: - The component with the style added. + A component with the style added; ``self`` if nothing changed. """ new_self = super()._add_style_recursive(style, theme) - are_components_touched = False - - if isinstance(self.contents, Var): - for component in _components_from_var(self.contents): - if isinstance(component, Component): - component._add_style_recursive(style, theme) - are_components_touched = True - - if are_components_touched: - GLOBAL_CACHE.clear() - - return new_self + if not isinstance(self.contents, Var): + return new_self + var_data = self.contents._var_data + if not var_data or not var_data.components: + return new_self + + rebuilt: list | None = None + for i, embedded in enumerate(var_data.components): + if not isinstance(embedded, Component): + continue + updated = embedded._add_style_recursive(style, theme) + if updated is embedded: + continue + if rebuilt is None: + rebuilt = list(var_data.components) + rebuilt[i] = updated + + if rebuilt is None: + return new_self + + new_var_data = VarData( + state=var_data.state, + field_name=var_data.field_name, + imports=var_data.old_school_imports(), + hooks=dict.fromkeys(var_data.hooks), + deps=list(var_data.deps), + position=var_data.position, + components=tuple(rebuilt), + ) + new_contents = dataclasses.replace( + self.contents, + _var_data=new_var_data, + ) + return new_self.copy_with(contents=new_contents) def _get_vars( self, include_children: bool = False, ignore_ids: set[int] | None = None diff --git a/packages/reflex-components-core/src/reflex_components_core/core/banner.py b/packages/reflex-components-core/src/reflex_components_core/core/banner.py index 8d9af0bef76..c912fd79818 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/banner.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/banner.py @@ -359,6 +359,7 @@ def create(cls, **props) -> Component: ) info_message = el.div( + warning_icon, el.span( "If you are the owner of this app, visit ", el.a( @@ -390,8 +391,6 @@ def create(cls, **props) -> Component: background_color=color("amber", 3), padding="0.625rem", ) - # Prepend warning icon into info_message children - info_message.children.insert(0, warning_icon) resume_button = el.a( el.button( diff --git a/packages/reflex-components-core/src/reflex_components_core/core/debounce.py b/packages/reflex-components-core/src/reflex_components_core/core/debounce.py index b627e87eea5..b077d558d1a 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/debounce.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/debounce.py @@ -7,6 +7,7 @@ from reflex_base.components.component import Component, field from reflex_base.constants import EventTriggers from reflex_base.event import EventHandler, no_args_event_spec +from reflex_base.style import Style from reflex_base.utils import format from reflex_base.vars import VarData from reflex_base.vars.base import Var @@ -94,9 +95,13 @@ def create(cls, *children: Component, **props: Any) -> Component: for p in cls.get_props() if getattr(child, p, None) is not None } - props[EventTriggers.ON_CHANGE] = child.event_triggers.pop( - EventTriggers.ON_CHANGE - ) + on_change = child.event_triggers[EventTriggers.ON_CHANGE] + child_event_triggers_minus_on_change = { + k: v + for k, v in child.event_triggers.items() + if k != EventTriggers.ON_CHANGE + } + props[EventTriggers.ON_CHANGE] = on_change props = {**props_from_child, **props} # Carry all other child props directly via custom_attrs @@ -114,9 +119,11 @@ def create(cls, *children: Component, **props: Any) -> Component: debounce_input_prop_names = { format.to_camel_case(prop) for prop in cls.get_props() } - for colliding_key in [k for k in child.style if k in debounce_input_prop_names]: - child.style.pop(colliding_key) - props.setdefault("style", {}).update(child.style) + cleaned_child_style = Style({ + k: v for k, v in child.style.items() if k not in debounce_input_prop_names + }) + cleaned_child = child.copy_with(style=cleaned_child_style) + props.setdefault("style", {}).update(cleaned_child_style) if child.class_name is not None: props["class_name"] = f"{props.get('class_name', '')} {child.class_name}" for prop_name in ("key", "special_props"): @@ -142,15 +149,19 @@ def create(cls, *children: Component, **props: Any) -> Component: ) component = super().create(**props) - component._get_style = child._get_style - component.event_triggers.update(child.event_triggers) - component.children = child.children - component._rename_props = child._rename_props # pyright: ignore[reportAttributeAccessIssue] outer_get_all_custom_code = component._get_all_custom_code - component._get_all_custom_code = lambda: ( - outer_get_all_custom_code() | (child._get_all_custom_code()) + return component.copy_with( + children=child.children, + event_triggers={ + **component.event_triggers, + **child_event_triggers_minus_on_change, + }, + _get_style=cleaned_child._get_style, + _rename_props=cleaned_child._rename_props, + _get_all_custom_code=lambda: ( + outer_get_all_custom_code() | child._get_all_custom_code() + ), ) - return component def _render(self): return super()._render().remove_props("ref") diff --git a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py index 1df59feda9d..81c09a0e3dd 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/foreach.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/foreach.py @@ -112,14 +112,15 @@ def create( ) try: # Keep a ref to a rendered component to determine correct imports/hooks/styles. - component.children = [component._render().render_component()] + return component.copy_with( + children=(component._render().render_component(),) + ) except UntypedVarError as e: raise UntypedVarError( iterable, "foreach", "https://reflex.dev/docs/library/dynamic-rendering/foreach/", ).with_traceback(e.__traceback__) from None - return component def _render(self) -> IterTag: props = {} diff --git a/packages/reflex-components-core/src/reflex_components_core/core/upload.py b/packages/reflex-components-core/src/reflex_components_core/core/upload.py index 9bbb28de64d..24d0e00e52c 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/upload.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/upload.py @@ -399,28 +399,30 @@ def create(cls, *children, **props) -> Component: ) # The file input to use. - upload = Input.create(type="file") - upload.special_props = [ - Var( - _js_expr=f"{input_props_unique_name}()", - _var_type=None, - _var_data=var_data, - ) - ] + upload = Input.create(type="file").copy_with( + special_props=[ + Var( + _js_expr=f"{input_props_unique_name}()", + _var_type=None, + _var_data=var_data, + ) + ] + ) # The dropzone to use. zone = Div.create( upload, *children, **{k: v for k, v in props.items() if k not in supported_props}, + ).copy_with( + special_props=[ + Var( + _js_expr=f"{root_props_unique_name}()", + _var_type=None, + _var_data=var_data, + ) + ] ) - zone.special_props = [ - Var( - _js_expr=f"{root_props_unique_name}()", - _var_type=None, - _var_data=var_data, - ) - ] return super().create( zone, diff --git a/packages/reflex-components-core/src/reflex_components_core/core/window_events.py b/packages/reflex-components-core/src/reflex_components_core/core/window_events.py index ee57bb770c0..d5515bbfb1f 100644 --- a/packages/reflex-components-core/src/reflex_components_core/core/window_events.py +++ b/packages/reflex-components-core/src/reflex_components_core/core/window_events.py @@ -99,12 +99,17 @@ def create(cls, **props) -> WindowEventListener: real_component = cast("WindowEventListener", super().create(**props)) memo_event_triggers = get_memoized_event_triggers(real_component) - if memo_event_triggers: - real_component.event_triggers = { - **real_component.event_triggers, - **memo_event_triggers, - } - return real_component + if not memo_event_triggers: + return real_component + return cast( + "WindowEventListener", + real_component.copy_with( + event_triggers={ + **real_component.event_triggers, + **memo_event_triggers, + } + ), + ) def _exclude_props(self) -> list[str]: """Exclude event handler props from being passed to Fragment. diff --git a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py index aef4a4ebf14..85662f064cb 100644 --- a/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py +++ b/packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py @@ -196,10 +196,11 @@ def create(cls, *children, **props): # Render the form hooks and use the hash of the resulting code to create a unique name. props["handle_submit_unique_name"] = "" form = super().create(*children, **props) - form.handle_submit_unique_name = md5( # pyright: ignore[reportAttributeAccessIssue] - str(form._get_all_hooks()).encode("utf-8") - ).hexdigest() - return form + return form.copy_with( + handle_submit_unique_name=md5( + str(form._get_all_hooks()).encode("utf-8") + ).hexdigest() + ) def add_imports(self) -> ImportDict: """Add imports needed by the form component. diff --git a/packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.py b/packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.py index 91b05cf358f..2cea79300f1 100644 --- a/packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.py +++ b/packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.py @@ -106,23 +106,29 @@ def add_imports(self) -> ImportDict: return {"": "gridjs/dist/theme/mermaid.css"} def _render(self) -> Tag: - if isinstance(self.data, Var) and types.is_dataframe(self.data._var_type): - self.columns = self.data._replace( - _js_expr=f"{self.data._js_expr}.columns", + columns: Any = self.columns + data: Any = self.data + if isinstance(data, Var) and types.is_dataframe(data._var_type): + columns = data._replace( + _js_expr=f"{data._js_expr}.columns", _var_type=list[Any], ) - self.data = self.data._replace( - _js_expr=f"{self.data._js_expr}.data", + data = data._replace( + _js_expr=f"{data._js_expr}.data", _var_type=list[list[Any]], ) - if types.is_dataframe(type(self.data)): + if types.is_dataframe(type(data)): # If given a pandas df break up the data and columns - data = serialize(self.data) - if not isinstance(data, dict): + serialized = serialize(data) + if not isinstance(serialized, dict): msg = "Serialized dataframe should be a dict." raise ValueError(msg) - self.columns = LiteralVar.create(data["columns"]) - self.data = LiteralVar.create(data["data"]) - - # Render the table. - return super()._render() + columns = LiteralVar.create(serialized["columns"]) + data = LiteralVar.create(serialized["data"]) + + props = { + attr.removesuffix("_"): getattr(self, attr) for attr in self.get_props() + } + props["columns"] = columns + props["data"] = data + return super()._render(props=props) diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/base.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/base.py index 136f77721e8..5ff441886ef 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/base.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/base.py @@ -120,12 +120,14 @@ def create( A new component instance. """ component = super().create(*children, **props) + updates: dict[str, Any] = { + "alias": "RadixThemes" + (component.tag or type(component).__name__), + } if component.library is None: - component.library = RadixThemesComponent.get_fields()[ + updates["library"] = RadixThemesComponent.get_fields()[ "library" ].default_value() - component.alias = "RadixThemes" + (component.tag or type(component).__name__) - return component + return component.copy_with(**updates) @staticmethod def _get_app_wrap_components() -> dict[tuple[int, str], Component]: diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.py index d0e089e9041..0589d4aea74 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/icon_button.py @@ -76,7 +76,7 @@ def create(cls, *children, **props) -> Component: raise ValueError(msg) if "size" in props: if isinstance(props["size"], str): - children[0].size = RADIX_TO_LUCIDE_SIZE[props["size"]] # pyright: ignore[reportAttributeAccessIssue] + icon_size = RADIX_TO_LUCIDE_SIZE[props["size"]] else: size_map_var = Match.create( props["size"], @@ -86,7 +86,8 @@ def create(cls, *children, **props) -> Component: if not isinstance(size_map_var, Var): msg = f"Match did not return a Var: {size_map_var}" raise ValueError(msg) - children[0].size = size_map_var # pyright: ignore[reportAttributeAccessIssue] + icon_size = size_map_var + children = [children[0].copy_with(size=icon_size), *children[1:]] return super().create(*children, **props) def add_style(self): diff --git a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.py b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.py index 477a1157f06..2771e31de8a 100644 --- a/packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.py +++ b/packages/reflex-components-radix/src/reflex_components_radix/themes/components/text_area.py @@ -112,8 +112,9 @@ def add_style(self): """ added_style: dict[str, dict] = {} added_style.setdefault("& textarea", {}) - if "padding" in self.style: - added_style["& textarea"]["padding"] = self.style.pop("padding") + padding = self.style.get("padding") + if padding is not None: + added_style["& textarea"]["padding"] = padding return added_style diff --git a/pyi_hashes.json b/pyi_hashes.json index 75a193845f0..bcb553fbf59 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -16,7 +16,7 @@ "packages/reflex-components-core/src/reflex_components_core/core/auto_scroll.pyi": "918dfad4d5925addd0f741e754b3b076", "packages/reflex-components-core/src/reflex_components_core/core/banner.pyi": "6040fbada9b96c55637a9c8cc21a5e10", "packages/reflex-components-core/src/reflex_components_core/core/clipboard.pyi": "e3950e0963a6d04299ff58294687e407", - "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "58138b5f1d5901839729d839620ea4da", + "packages/reflex-components-core/src/reflex_components_core/core/debounce.pyi": "1c6399c825728339cb0edee8ae9fb9b0", "packages/reflex-components-core/src/reflex_components_core/core/helmet.pyi": "7fd81a99bde5b0ff94bb52523597fd5c", "packages/reflex-components-core/src/reflex_components_core/core/html.pyi": "753d6ae315369530dad450ed643f5be6", "packages/reflex-components-core/src/reflex_components_core/core/sticky.pyi": "ba60a7d9cba75b27a1133bd63a9fbd59", diff --git a/reflex/app.py b/reflex/app.py index 65ae0a8235b..b6a0665fdfa 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1076,19 +1076,17 @@ def _app_root(self, app_wrappers: dict[tuple[int, str], Component]) -> Component for component in tuple(app_wrappers.values()): app_wrappers.update(component._get_all_app_wrap_components()) order = sorted(app_wrappers, key=operator.itemgetter(0), reverse=True) - root = copy.deepcopy(app_wrappers[order[0]]) - def reducer(parent: Component, key: tuple[int, str]) -> Component: - child = copy.deepcopy(app_wrappers[key]) - parent.children.append(child) - return child - - functools.reduce( - lambda parent, key: reducer(parent, key), - order[1:], - root, - ) - return root + result: Component | None = None + for key in reversed(order): + wrapper = copy.deepcopy(app_wrappers[key]) + result = ( + wrapper + if result is None + else wrapper.copy_with(children=(*wrapper.children, result)) + ) + assert result is not None + return result def _should_compile(self) -> bool: """Check if the app should be compiled. @@ -1117,9 +1115,7 @@ def _setup_sticky_badge(self): @memo def memoized_badge(): - sticky_badge = sticky() - sticky_badge._add_style_recursive({}) - return sticky_badge + return sticky()._add_style_recursive({}) self.app_wraps[0, "StickyBadge"] = lambda _: memoized_badge() diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 39ff4931a9e..bc5dfcd6dd6 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -885,7 +885,7 @@ def compile_unevaluated_page( # Generate the component if it is a callable. component = into_component(page.component) - component._add_style_recursive(style or {}, theme) + component = component._add_style_recursive(style or {}, theme) from reflex_base.utils.format import make_default_page_title @@ -905,7 +905,7 @@ def compile_unevaluated_page( meta_args["description"] = page.description # Add meta information to the component. - utils.add_meta( + component = utils.add_meta( component, **meta_args, ) diff --git a/reflex/compiler/plugins/builtin.py b/reflex/compiler/plugins/builtin.py index a4b326be4ab..f6f1a2adf7f 100644 --- a/reflex/compiler/plugins/builtin.py +++ b/reflex/compiler/plugins/builtin.py @@ -54,7 +54,7 @@ def eval_page( if (description := getattr(page, "description", None)) is not None: meta_args["description"] = description - utils.add_meta(component, **meta_args) + component = utils.add_meta(component, **meta_args) except Exception as err: if hasattr(err, "add_note"): err.add_note(f"Happened while evaluating page {page.route!r}") @@ -74,41 +74,21 @@ class ApplyStylePlugin(Plugin): style: ComponentStyle | None = None @staticmethod - def _apply_style( - comp: Component, style: ComponentStyle, page_context: PageContext - ) -> Component | None: + def _apply_style(comp: Component, style: ComponentStyle) -> Component | None: """Apply app-level styles to a single component. Args: comp: The component to style. style: The app-level component style map. - page_context: The active page context, used to obtain a page-local - clone before rewriting ``style``. Returns: - A page-local clone with the merged style, or ``None`` when the - component has no type-level or app-level style to apply. + A new frozen component carrying the merged style, or ``None`` + when the component has no type-level or app-level style to apply. """ - if type(comp)._add_style != Component._add_style: - msg = "Do not override _add_style directly. Use add_style instead." - raise UserWarning(msg) - - new_style = comp._add_style() - component_style = comp._get_component_style(style) - if not new_style and not component_style: + new_style = utils.merge_component_style(comp, style) + if new_style is None: return None - - style_vars = [new_style._var_data] - if component_style: - new_style.update(component_style) - style_vars.append(component_style._var_data) - new_style.update(comp.style) - style_vars.append(comp.style._var_data) - new_style._var_data = VarData.merge(*style_vars) - - owned = page_context.own(comp) - owned.style = new_style - return owned + return comp.copy_with(style=new_style) def enter_component( self, @@ -122,11 +102,11 @@ def enter_component( """Apply the non-recursive portion of ``_add_style_recursive``. Returns: - A page-local clone carrying the merged style, or ``None`` when no - style change applies to this component. + A new frozen component carrying the merged style, or ``None`` when + no style change applies to this component. """ if self.style is not None and isinstance(comp, Component) and not in_prop_tree: - return self._apply_style(comp, self.style, page_context) + return self._apply_style(comp, self.style) return None def _compiler_bind_enter_component( @@ -158,7 +138,7 @@ def enter_component( ) -> BaseComponent | None: if not isinstance(comp, Component) or in_prop_tree: return None - return apply_style(comp, style, page_context) + return apply_style(comp, style) return enter_component diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index cdd7c3e7c49..bea2f476a12 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -314,8 +314,7 @@ def leave_component( children, comp.children, strict=True ) ): - comp = page_context.own(comp) - comp.children = list(children) + comp = comp.copy_with(children=tuple(children)) strategy = get_memoization_strategy(comp) if strategy is MemoizationStrategy.SNAPSHOT: diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index c2bdf618650..f2235cf9176 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -377,33 +377,59 @@ def _apply_component_style_for_compile(component: Component) -> Component: Returns: The styled component tree. """ - component._add_style_recursive(_app_style()) - return component + return component._add_style_recursive(_app_style()) -def _apply_root_style(component: Component) -> None: - """Merge app-level style into ``component.style`` without recursing. - - Used for passthrough memo bodies where descendants render (and are styled) - in the page scope — only the root's style needs merging here. +def merge_component_style( + component: Component, app_style: ComponentStyle | Style +) -> Style | None: + """Compute the final style for a single component given app-level style. Args: - component: The root component to style in place. + component: The component to style. + app_style: The app-level component style map. + + Returns: + The merged style, or ``None`` when no type-level or app-level style + applies (and the caller can leave ``component.style`` untouched). + + Raises: + UserWarning: If ``_add_style`` has been overridden. """ if type(component)._add_style != Component._add_style: msg = "Do not override _add_style directly. Use add_style instead." raise UserWarning(msg) - style = _app_style() - new_style = component._add_style() + style_addition = component._add_style() + component_style = component._get_component_style(app_style) + if not style_addition and not component_style: + return None + new_style = style_addition style_vars = [new_style._var_data] - component_style = component._get_component_style(style) if component_style: new_style.update(component_style) style_vars.append(component_style._var_data) new_style.update(component.style) style_vars.append(component.style._var_data) new_style._var_data = VarData.merge(*style_vars) - component.style = new_style + return new_style + + +def _apply_root_style(component: Component) -> Component: + """Merge app-level style into ``component.style`` without recursing. + + Used for passthrough memo bodies where descendants render (and are styled) + in the page scope — only the root's style needs merging here. + + Args: + component: The root component to style. + + Returns: + A component with the root style applied; the same instance if nothing changed. + """ + new_style = merge_component_style(component, _app_style()) + if new_style is None: + return component + return component.copy_with(style=new_style) def _app_style() -> ComponentStyle | Style: @@ -433,30 +459,26 @@ def compile_experimental_component_memo( """ hole_child = definition.passthrough_hole_child if hole_child is not None: - # Passthrough memo: shallow-copy the root only — ``render.children`` - # still aliases the user-authored descendants so root-level walkers - # (e.g. ``Form._get_form_refs``) can introspect the real subtree, but - # we skip the O(n) deepcopy + recursive style pass. Descendants are - # rendered AND styled in the page scope, not here, so only the root - # needs app-level style merged. - render = copy.copy(definition.component) - _apply_root_style(render) - - hooks = _root_only_hooks(render) - custom_code = _root_only_custom_code(render) - dynamic_imports = _root_only_dynamic_imports(render) + # Passthrough memo: descendants render (and are styled) in the page + # scope, so only the root needs app-level style merged. The original + # subtree is left alone — frozen components are immutable, so the + # root walkers (``Form._get_form_refs`` etc.) read off the original + # children before we swap in the JSX hole. + styled_root = _apply_root_style(definition.component) + + hooks = _root_only_hooks(styled_root) + custom_code = _root_only_custom_code(styled_root) + dynamic_imports = _root_only_dynamic_imports(styled_root) # Strings returned by the root's ``add_hooks`` can reference symbols # (``refs``, ``StateContexts``, etc.) that normally reach this module # through descendants' ``_get_hooks_imports`` / ``_get_imports``. JS # imports are side-effect-free and dedup cleanly, so pulling the # whole subtree's imports here is safe even when some go unused. - # ``_get_all_imports`` is read-only on the descendants, so the shallow - # aliasing above is fine. - all_imports = render._get_all_imports() + all_imports = styled_root._get_all_imports() # Swap children for JSX render: the memo body template emits a # ``{children}`` hole in place of the real descendants. - render.children = [hole_child] + render = styled_root.copy_with(children=(hole_child,)) rendered = render.render() else: render = _apply_component_style_for_compile(copy.deepcopy(definition.component)) @@ -821,15 +843,12 @@ def add_meta( item if isinstance(item, Component) else Meta.create(**item) for item in meta ] - children: list[Any] = [Title.create(title)] + extras: list[Any] = [Title.create(title)] if description: - children.append(Description.create(content=description)) - children.append(Image.create(content=image)) - - page.children.extend(children) - page.children.extend(meta_tags) + extras.append(Description.create(content=description)) + extras.append(Image.create(content=image)) - return page + return page.copy_with(children=(*page.children, *extras, *meta_tags)) def resolve_path_of_web_dir(path: str | Path) -> Path: diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py index 3c88aca6cff..9690c92b195 100644 --- a/reflex/experimental/memo.py +++ b/reflex/experimental/memo.py @@ -534,9 +534,10 @@ def _lift_rest_props(component: Component) -> Component: rewritten_children.append(child) - component.children = rewritten_children - component.special_props = special_props - return component + return component.copy_with( + children=tuple(rewritten_children), + special_props=special_props, + ) def _analyze_params( @@ -1045,9 +1046,8 @@ def create_passthrough_component_memo( captured_hole_child: list[Component] = [] def passthrough(children: Var[Component]) -> Component: - new_component = copy(component) if render_snapshot: - return new_component + return copy(component) hole_bare = Bare.create(children) captured_hole_child.append(hole_bare) # Substitute the ``{children}`` hole for the original descendants so @@ -1055,7 +1055,7 @@ def passthrough(children: Var[Component]) -> Component: # specific children at any given call site. Original descendants stay # reachable on the page-level wrapper via the plugin's # ``_get_all_refs`` delegation back to the source component. - new_component.children = [hole_bare] + new_component = component.copy_with(children=(hole_bare,)) # Compile-time walkers that need the real subtree (notably # ``Form._get_form_refs`` collecting id-based input refs into the # generated ``handleSubmit`` JS) call ``self._get_all_refs()`` while diff --git a/reflex/state.py b/reflex/state.py index e3e959a2d44..2e29076c4e1 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -2561,8 +2561,7 @@ def create(cls, *children, **props) -> Component: setattr(reflex.istate.dynamic, state_cls_name, component_state) component = component_state.get_component(*children, **props) component = into_component(component) - component.State = component_state - return component + return component.copy_with(State=component_state) @dataclasses.dataclass( diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index a715b930436..af3d4c20347 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -534,7 +534,7 @@ def test_shared_parent_instance_across_pages_preserves_original() -> None: for the memo tag on the second page. """ shared_parent = Fragment.create(WithProp.create(label=STATE_VAR)) - original_children = list(shared_parent.children) + original_children = tuple(shared_parent.children) original_child = shared_parent.children[0] ctx = CompileContext( @@ -578,9 +578,9 @@ def test_shared_nested_parent_mirroring_common_elements_preserves_original() -> inner_parent, WithProp.create(label=LiteralVar.create("trailing")), ) - original_outer_children = list(shared_outer.children) + original_outer_children = tuple(shared_outer.children) original_inner = shared_outer.children[1] - original_inner_children = list(inner_parent.children) + original_inner_children = tuple(inner_parent.children) original_innermost = inner_parent.children[0] ctx = CompileContext( @@ -646,8 +646,9 @@ def create(cls, *children, **props): state="LeafState", ), ) - internal_child = Plain.create(*children) - internal_child.special_props = [internal_hook_var] + internal_child = Plain.create(*children).copy_with( + special_props=[internal_hook_var] + ) return super().create(internal_child, **props) stateful_event = Var(_js_expr="evt")._replace( @@ -655,7 +656,9 @@ def create(cls, *children, **props): merge_var_data=VarData(state="LeafState"), ) leaf = StatefulLeaf.create() - leaf.event_triggers["on_something"] = stateful_event + leaf = leaf.copy_with( + event_triggers={**leaf.event_triggers, "on_something": stateful_event} + ) ctx, page_ctx = _compile_single_page(lambda: leaf) @@ -1888,8 +1891,7 @@ def test_hooks_only_var_data_descendant_inside_snapshot_boundary_is_memoized() - hook_var = Var(_js_expr="hookOnlyProbe")._replace( merge_var_data=VarData(hooks={"const hookOnlyProbe = useHookOnly();": None}) ) - child = Plain.create() - child.special_props = [hook_var] + child = Plain.create().copy_with(special_props=[hook_var]) boundary = LeafComponent.create(child) ctx, page_ctx = _compile_single_page(lambda: boundary) diff --git a/tests/units/compiler/test_plugins.py b/tests/units/compiler/test_plugins.py index 26eb1f39c99..8e4c6ab430e 100644 --- a/tests/units/compiler/test_plugins.py +++ b/tests/units/compiler/test_plugins.py @@ -2,7 +2,7 @@ import dataclasses from collections.abc import Callable -from typing import Any +from typing import Any, cast import pytest from reflex_base.components.component import ( @@ -712,9 +712,10 @@ class AnotherDynamicContext(BaseContext): def test_apply_style_plugin_matches_legacy_style_behavior() -> None: component = create_component_tree() - legacy_component = create_component_tree() - - legacy_component._add_style_recursive(page_style()) + legacy_component: RootComponent = cast( + "RootComponent", + create_component_tree()._add_style_recursive(page_style()), + ) original_style_snapshot = normalize_style(component) original_child_style_snapshot = normalize_style(component.children[0]) diff --git a/tests/units/components/core/test_foreach.py b/tests/units/components/core/test_foreach.py index cec2db1c95a..b6db359ae2e 100644 --- a/tests/units/components/core/test_foreach.py +++ b/tests/units/components/core/test_foreach.py @@ -283,7 +283,7 @@ def test_foreach_component_styles(): display_color, ) ) - component._add_style_recursive({box: {"color": "red"}}) + component = component._add_style_recursive({box: {"color": "red"}}) assert 'css:({ ["color"] : "red" })' in str(component) diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 9ca3495a2a2..1ce1bb4cbba 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -317,7 +317,7 @@ def test_create_component(component1): attrs = {"color": "white", "text_align": "center"} c = component1.create(*children, **attrs) assert isinstance(c, component1) - assert c.children == children + assert c.children == tuple(children) assert ( str(LiteralVar.create(c.style)) == '({ ["color"] : "white", ["textAlign"] : "center" })' @@ -468,7 +468,7 @@ def test_create_component_prop_validation( with ctx: c = component1.create(**kwargs) assert isinstance(c, component1) - assert c.children == [] + assert c.children == () assert c.style == {} @@ -2128,7 +2128,7 @@ def add_style(self): # pyright: ignore [reportIncompatibleMethodOverride] } page = rx.vstack(StyledComponent.create()) - page._add_style_recursive(Style()) + page = page._add_style_recursive(Style()) assert ( f"const {test_state.get_name()} = useContext(StateContexts.{test_state.get_name()})" @@ -2153,7 +2153,7 @@ def add_style(self): return Style({"color": "red"}) page = rx.vstack(rx.foreach(Var.range(3), lambda i: StyledComponent.create(i))) - page._add_style_recursive(Style()) + page = page._add_style_recursive(Style()) # Expect only a single child of the foreach on the python side assert len(page.children[0].children) == 1 @@ -2301,3 +2301,115 @@ def test_ref(): assert id_component._render().props["ref"].equals(Var("ref_custom_id")) assert "ref" not in rx.box()._render().props + + +def test_component_frozen_after_create(): + """A component returned from ``create`` rejects post-construction writes.""" + c = rx.box() + with pytest.raises(AttributeError, match="copy_with"): + c.style = Style() + + +def test_children_is_tuple_after_create(): + """Children should be stored as an immutable tuple.""" + c = rx.box(rx.text("a"), rx.text("b")) + assert isinstance(c.children, tuple) + assert not hasattr(c.children, "append") + + +def test_copy_with_returns_new_frozen_instance(): + """``copy_with`` returns a new frozen instance with overrides applied.""" + a = rx.box(rx.text("a"), id="orig") + b = a.copy_with(id="new") + assert a is not b + assert a.id == "orig" + assert b.id == "new" + assert a.children is b.children + with pytest.raises(AttributeError): + b.id = "still_new" + + +def test_copy_with_drops_render_caches(): + """``copy_with`` drops render-path caches that may depend on replaced fields.""" + a = rx.box() + a.render() + assert "_cached_render_result" in a.__dict__ + b = a.copy_with(id="x") + assert "_cached_render_result" not in b.__dict__ + + +def test_copy_with_coerces_children_list_to_tuple(): + """``copy_with(children=[...])`` should coerce the iterable to a tuple.""" + a = rx.box() + b = a.copy_with(children=[rx.text("x")]) + assert isinstance(b.children, tuple) + assert len(b.children) == 1 + + +def test_cache_writes_allowed_on_frozen(): + """Cache attribute writes should bypass the freeze guard.""" + c = rx.box(rx.text("a")) + # These populate the various caches and would raise if blocked. + c.render() + c._get_imports() + c._get_hooks_internal() + list(c._get_vars()) + + +def test_cached_property_on_frozen_component(): + """``cached_property`` should work on frozen components via __dict__ writes.""" + c = rx.box(rx.text("a")) + result1 = c._get_component_prop_property + result2 = c._get_component_prop_property + assert result1 is result2 + assert "_get_component_prop_property" in c.__dict__ + + +def test_unsafe_create_returns_frozen(): + """``Bare`` (uses ``_unsafe_create``) returns a frozen instance.""" + bare = Bare.create("hello") + with pytest.raises(AttributeError, match="copy_with"): + bare.tag = "div" + + +def test_add_style_recursive_no_op_returns_self(): + """A recursive style pass with no addition and no child change returns ``self``.""" + + class NoStyleComponent(Component): + tag = "NoStyleComponent" + + parent = NoStyleComponent.create(NoStyleComponent.create()) + result = parent._add_style_recursive({}) + assert result is parent + + +def test_bare_add_style_recursive_threads_var_components(): + """A styled embedded component reachable via ``Var._var_data.components`` is rethreaded.""" + + class StyledEmbedded(Component): + tag = "StyledEmbedded" + + def add_style(self): + return Style({"color": "red"}) + + embedded = StyledEmbedded.create() + contents = Var( + _js_expr="value", + _var_type=str, + _var_data=VarData(components=[embedded]), + ) + bare = Bare.create(contents) + result = bare._add_style_recursive({}) + + assert result is not bare + assert isinstance(result, Bare) + assert isinstance(result.contents, Var) + new_var_data = result.contents._var_data + assert new_var_data is not None + new_components = new_var_data.components + assert new_components + rebuilt_embedded = new_components[0] + assert rebuilt_embedded is not embedded + assert isinstance(rebuilt_embedded, Component) + assert "color" in rebuilt_embedded.style + assert "color" not in embedded.style diff --git a/tests/units/experimental/test_memo.py b/tests/units/experimental/test_memo.py index efb006d545d..f1bfb2e55b8 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/experimental/test_memo.py @@ -520,7 +520,7 @@ def transparent(children: rx.Var[rx.Component]) -> rx.Component: parent = ValidParent.create(wrapped_child) assert isinstance(wrapped_child, ExperimentalMemoComponent) - assert parent.children == [wrapped_child] + assert parent.children == (wrapped_child,) def test_compile_memo_components_includes_experimental_custom_code(): From d1d22c56e3da852d345488ff984822b461cb1ed7 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 7 May 2026 22:51:21 +0500 Subject: [PATCH 2/3] perf: speed up Component construction hot path Bypass __setattr__'s freeze guard during init via vars(self).update(...); a brand-new instance can't be frozen, so the per-attribute check is pure overhead. Applied in BaseComponent.__init__, Component.__init__, and Component._create. In Component.__init__: - Lazily allocate event_triggers only when a trigger is actually seen; most components have none, so the up-front dict copy was wasted work. - Single-pass kwargs loop that handles event triggers, var props, and unknown on_* errors inline instead of double-iterating. - Defer the _validate_children imports until after the fast no-op exit. --- .../src/reflex_base/components/component.py | 105 +++++++++--------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 0a0db0f0df0..9356684f4c6 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -306,11 +306,13 @@ def __init__( """ if "children" in kwargs: kwargs["children"] = tuple(kwargs["children"]) - for key, value in kwargs.items(): - setattr(self, key, value) + # Bypass ``__setattr__``'s freeze guard: a brand-new instance can't be + # frozen, so the per-attribute check is pure overhead during init. + d = vars(self) + d.update(kwargs) for name, value in self.get_fields().items(): if name not in kwargs: - setattr(self, name, value.default_value()) + d[name] = value.default_value() def __setattr__(self, key: str, value: Any) -> None: """Block writes to frozen components, except for cache attributes. @@ -946,76 +948,70 @@ def _post_init(self, *args, **kwargs): component_specific_triggers = self.get_event_triggers() props = self.get_props() - # Add any events triggers. - if "event_triggers" not in kwargs: - kwargs["event_triggers"] = {} - kwargs["event_triggers"] = kwargs["event_triggers"].copy() + # Lazily allocate the event_triggers dict only when a trigger is found. + # Most components have no events; allocating up-front is pure waste. + existing_triggers = kwargs.get("event_triggers") + event_triggers: dict[str, Any] | None = ( + dict(existing_triggers) if existing_triggers else None + ) + event_keys: list[str] = [] # Iterate through the kwargs and set the props. for key, value in kwargs.items(): - if ( - key.startswith("on_") - and key not in component_specific_triggers - and key not in props - ): - valid_triggers = sorted(component_specific_triggers.keys()) - msg = ( - f"The {(comp_name := type(self).__name__)} does not take in an `{key}` event trigger. " - f"Valid triggers for {comp_name}: {valid_triggers}. " - f"If {comp_name} is a third party component make sure to add `{key}` to the component's event triggers. " - f"visit https://reflex.dev/docs/wrapping-react/guide/#event-triggers for more info." - ) - raise ValueError(msg) if key in component_specific_triggers: - # Event triggers are bound to event chains. - is_var = False - elif key in props: - # Set the field type. - is_var = ( - field.type_origin is Var if (field := fields.get(key)) else False + if event_triggers is None: + event_triggers = {} + event_triggers[key] = EventChain.create( + value=value, + args_spec=component_specific_triggers[key], + key=key, ) - else: + event_keys.append(key) continue - # Check whether the key is a component prop. - if is_var: + if key in props: + field = fields.get(key) + if field is None or field.type_origin is not Var: + continue try: kwargs[key] = LiteralVar.create(value) - - # Get the passed type and the var type. passed_type = kwargs[key]._var_type expected_type = typing.get_args( types.get_field_type(type(self), key) )[0] except TypeError: - # If it is not a valid var, check the base types. passed_type = type(value) expected_type = types.get_field_type(type(self), key) if not satisfies_type_hint(value, expected_type): value_name = value._js_expr if isinstance(value, Var) else value - additional_info = ( " You can call `.bool()` on the value to convert it to a boolean." if expected_type is bool and isinstance(value, Var) else "" ) - raise TypeError( f"Invalid var passed for prop {type(self).__name__}.{key}, expected type {expected_type}, got value {value_name} of type {passed_type}." + additional_info ) - # Check if the key is an event trigger. - if key in component_specific_triggers: - kwargs["event_triggers"][key] = EventChain.create( - value=value, - args_spec=component_specific_triggers[key], - key=key, + continue + + if key.startswith("on_"): + valid_triggers = sorted(component_specific_triggers.keys()) + comp_name = type(self).__name__ + msg = ( + f"The {comp_name} does not take in an `{key}` event trigger. " + f"Valid triggers for {comp_name}: {valid_triggers}. " + f"If {comp_name} is a third party component make sure to add `{key}` to the component's event triggers. " + f"visit https://reflex.dev/docs/wrapping-react/guide/#event-triggers for more info." ) + raise ValueError(msg) - # Remove any keys that were added as events. - for key in kwargs["event_triggers"]: - kwargs.pop(key, None) + # Promote any registered event triggers; drop the raw on_* keys. + if event_triggers is not None: + kwargs["event_triggers"] = event_triggers + for key in event_keys: + kwargs.pop(key, None) # Place data_ and aria_ attributes into custom_attrs special_attributes = [ @@ -1086,9 +1082,9 @@ def _post_init(self, *args, **kwargs): ): msg = f"Invalid class_name passed for prop {type(self).__name__}.class_name, expected type str, got value {class_name._js_expr} of type {class_name._var_type}." raise TypeError(msg) - # Construct the component. - for key, value in kwargs.items(): - setattr(self, key, value) + # Construct the component. Bypass ``__setattr__``'s freeze guard: the + # instance is freshly created and not yet frozen. + vars(self).update(kwargs) @classmethod def get_event_triggers(cls) -> dict[str, types.ArgsSpec | Sequence[types.ArgsSpec]]: @@ -1300,8 +1296,8 @@ def _unsafe_create( children_tuple = tuple(children) comp = cls.__new__(cls) super(Component, comp).__init__(id=props.get("id"), children=children_tuple) - for prop, value in props.items(): - setattr(comp, prop, value) + # Bypass ``__setattr__``'s freeze guard: ``comp`` is not yet frozen. + vars(comp).update(props) comp._freeze() return comp @@ -1533,19 +1529,18 @@ def _validate_component_children(self, children: list[Component]): children: The children of the component. """ - from reflex_components_core.base.fragment import Fragment - from reflex_components_core.core.cond import Cond - from reflex_components_core.core.foreach import Foreach - from reflex_components_core.core.match import Match - - no_valid_parents_defined = all(child._valid_parents == [] for child in children) if ( not self._invalid_children and not self._valid_children - and no_valid_parents_defined + and all(child._valid_parents == [] for child in children) ): return + from reflex_components_core.base.fragment import Fragment + from reflex_components_core.core.cond import Cond + from reflex_components_core.core.foreach import Foreach + from reflex_components_core.core.match import Match + comp_name = type(self).__name__ allowed_components = [ comp.__name__ for comp in (Fragment, Foreach, Cond, Match) From 78f1602b2fa1e314972f1291c32640ac45a8ce05 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 7 May 2026 22:55:52 +0500 Subject: [PATCH 3/3] fix: preserve empty event_triggers dict and rename shadowed field var Use so an explicitly-passed empty event_triggers dict still gets copied instead of being dropped. Rename the local to to avoid shadowing the imported helper. --- .../reflex-base/src/reflex_base/components/component.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 9356684f4c6..32f6c0252de 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -952,7 +952,7 @@ def _post_init(self, *args, **kwargs): # Most components have no events; allocating up-front is pure waste. existing_triggers = kwargs.get("event_triggers") event_triggers: dict[str, Any] | None = ( - dict(existing_triggers) if existing_triggers else None + dict(existing_triggers) if existing_triggers is not None else None ) event_keys: list[str] = [] @@ -970,8 +970,8 @@ def _post_init(self, *args, **kwargs): continue if key in props: - field = fields.get(key) - if field is None or field.type_origin is not Var: + field_def = fields.get(key) + if field_def is None or field_def.type_origin is not Var: continue try: kwargs[key] = LiteralVar.create(value)