Skip to content

Commit 300a044

Browse files
committed
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.
1 parent 1ffc3b5 commit 300a044

28 files changed

Lines changed: 473 additions & 286 deletions

File tree

packages/reflex-base/src/reflex_base/components/component.py

Lines changed: 106 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast, get_args, get_origin
1919

2020
from rich.markup import escape
21-
from typing_extensions import dataclass_transform
21+
from typing_extensions import Self, dataclass_transform
2222

2323
from reflex_base import constants
2424
from reflex_base.breakpoints import Breakpoints
@@ -266,9 +266,20 @@ class BaseComponent(metaclass=BaseComponentMeta):
266266
This is something that can be rendered as a Component via the Reflex compiler.
267267
"""
268268

269-
children: list[BaseComponent] = field(
269+
_frozen: ClassVar[bool] = False
270+
271+
# Render-path caches; allowed to be written even on frozen instances.
272+
_CACHE_ATTRS: ClassVar[frozenset[str]] = frozenset({
273+
"_cached_render_result",
274+
"_vars_cache",
275+
"_imports_cache",
276+
"_hooks_internal_cache",
277+
"_get_component_prop_property",
278+
})
279+
280+
children: tuple[BaseComponent, ...] = field(
270281
doc="The children nested within the component.",
271-
default_factory=list,
282+
default_factory=tuple,
272283
is_javascript_property=False,
273284
)
274285

@@ -293,24 +304,73 @@ def __init__(
293304
Args:
294305
**kwargs: The kwargs to pass to the component.
295306
"""
307+
if "children" in kwargs:
308+
kwargs["children"] = tuple(kwargs["children"])
296309
for key, value in kwargs.items():
297310
setattr(self, key, value)
298311
for name, value in self.get_fields().items():
299312
if name not in kwargs:
300313
setattr(self, name, value.default_value())
301314

315+
def __setattr__(self, key: str, value: Any) -> None:
316+
"""Block writes to frozen components, except for cache attributes.
317+
318+
Args:
319+
key: The attribute name.
320+
value: The attribute value.
321+
322+
Raises:
323+
AttributeError: If the component is frozen and the attribute is not a cache.
324+
"""
325+
if self.__dict__.get("_frozen", False) and key not in type(self)._CACHE_ATTRS:
326+
msg = (
327+
f"Cannot set {key!r} on frozen {type(self).__name__}; "
328+
"use copy_with() to create a modified copy."
329+
)
330+
raise AttributeError(msg)
331+
super().__setattr__(key, value)
332+
333+
def _freeze(self) -> None:
334+
"""Mark this component as frozen.
335+
336+
Subsequent attribute writes outside the cache allowlist will raise.
337+
"""
338+
object.__setattr__(self, "_frozen", True)
339+
340+
def copy_with(self, **updates: Any) -> Self:
341+
"""Return a frozen shallow copy with updated fields.
342+
343+
Bypasses ``__setattr__`` for speed and to skip the freeze guard.
344+
Render-path caches are dropped because they may depend on the fields
345+
being replaced.
346+
347+
Args:
348+
**updates: Field values to override on the copy.
349+
350+
Returns:
351+
A new frozen instance with the requested updates applied.
352+
"""
353+
new = self.__class__.__new__(self.__class__)
354+
d = vars(new)
355+
d.update(vars(self))
356+
for cache_attr in type(self)._CACHE_ATTRS:
357+
d.pop(cache_attr, None)
358+
if "children" in updates:
359+
updates["children"] = tuple(updates["children"])
360+
d.update(updates)
361+
d["_frozen"] = True
362+
return new
363+
302364
def set(self, **kwargs):
303-
"""Set the component props.
365+
"""Set the component props, returning a new frozen instance.
304366
305367
Args:
306368
**kwargs: The kwargs to set.
307369
308370
Returns:
309-
The component with the updated props.
371+
A new component with the updated props.
310372
"""
311-
for key, value in kwargs.items():
312-
setattr(self, key, value)
313-
return self
373+
return self.copy_with(**kwargs)
314374

315375
def __copy__(self) -> BaseComponent:
316376
"""Return a shallow copy suitable for compile-time mutation.
@@ -327,13 +387,7 @@ def __copy__(self) -> BaseComponent:
327387
new = self.__class__.__new__(self.__class__)
328388
new_dict = vars(new)
329389
new_dict.update(vars(self))
330-
for attr in (
331-
"_cached_render_result",
332-
"_vars_cache",
333-
"_imports_cache",
334-
"_hooks_internal_cache",
335-
"_get_component_prop_property",
336-
):
390+
for attr in type(self)._CACHE_ATTRS:
337391
new_dict.pop(attr, None)
338392
return new
339393

@@ -1223,9 +1277,11 @@ def _create(cls: type[T], children: Sequence[BaseComponent], **props: Any) -> T:
12231277
Returns:
12241278
The component.
12251279
"""
1280+
children_tuple = tuple(children)
12261281
comp = cls.__new__(cls)
1227-
super(Component, comp).__init__(id=props.get("id"), children=list(children))
1228-
comp._post_init(children=list(children), **props)
1282+
super(Component, comp).__init__(id=props.get("id"), children=children_tuple)
1283+
comp._post_init(children=children_tuple, **props)
1284+
comp._freeze()
12291285
return comp
12301286

12311287
@classmethod
@@ -1241,10 +1297,12 @@ def _unsafe_create(
12411297
Returns:
12421298
The component.
12431299
"""
1300+
children_tuple = tuple(children)
12441301
comp = cls.__new__(cls)
1245-
super(Component, comp).__init__(id=props.get("id"), children=list(children))
1302+
super(Component, comp).__init__(id=props.get("id"), children=children_tuple)
12461303
for prop, value in props.items():
12471304
setattr(comp, prop, value)
1305+
comp._freeze()
12481306
return comp
12491307

12501308
def add_style(self) -> dict[str, Any] | None:
@@ -1311,40 +1369,47 @@ def _add_style_recursive(
13111369
theme: The theme to apply. (for retro-compatibility with deprecated _apply_theme API)
13121370
13131371
Returns:
1314-
The component with the additional style.
1372+
A component with the additional style; ``self`` if nothing changed.
13151373
13161374
Raises:
13171375
UserWarning: If `_add_style` has been overridden.
13181376
"""
1319-
# 1. Default style from `_add_style`/`add_style`.
13201377
if type(self)._add_style != Component._add_style:
13211378
msg = "Do not override _add_style directly. Use add_style instead."
13221379
raise UserWarning(msg)
1323-
new_style = self._add_style()
1324-
style_vars = [new_style._var_data]
13251380

1326-
# 2. User-defined style from `App.style`.
1381+
style_addition = self._add_style()
13271382
component_style = self._get_component_style(style)
1328-
if component_style:
1329-
new_style.update(component_style)
1330-
style_vars.append(component_style._var_data)
1331-
1332-
# 4. style dict and css props passed to the component instance.
1333-
new_style.update(self.style)
1334-
style_vars.append(self.style._var_data)
1335-
1336-
new_style._var_data = VarData.merge(*style_vars)
1337-
1338-
# Assign the new style
1339-
self.style = new_style
1383+
has_style_change = bool(style_addition) or bool(component_style)
13401384

1341-
# Recursively add style to the children.
1342-
for child in self.children:
1343-
# Skip non-Component children.
1385+
new_children: list | None = None
1386+
for i, child in enumerate(self.children):
13441387
if not isinstance(child, Component):
13451388
continue
1346-
child._add_style_recursive(style, theme)
1347-
return self
1389+
updated = child._add_style_recursive(style, theme)
1390+
if updated is child:
1391+
continue
1392+
if new_children is None:
1393+
new_children = list(self.children)
1394+
new_children[i] = updated
1395+
1396+
if not has_style_change and new_children is None:
1397+
return self
1398+
1399+
updates: dict[str, Any] = {}
1400+
if has_style_change:
1401+
new_style = style_addition
1402+
style_vars = [new_style._var_data]
1403+
if component_style:
1404+
new_style.update(component_style)
1405+
style_vars.append(component_style._var_data)
1406+
new_style.update(self.style)
1407+
style_vars.append(self.style._var_data)
1408+
new_style._var_data = VarData.merge(*style_vars)
1409+
updates["style"] = new_style
1410+
if new_children is not None:
1411+
updates["children"] = tuple(new_children)
1412+
return self.copy_with(**updates)
13481413

13491414
def _get_style(self) -> dict:
13501415
"""Get the style for the component.
@@ -2342,8 +2407,7 @@ def get_component(self) -> Component:
23422407
except Exception:
23432408
style = {}
23442409

2345-
component._add_style_recursive(style)
2346-
return component
2410+
return component._add_style_recursive(style)
23472411

23482412
def _get_all_app_wrap_components(
23492413
self, *, ignore_ids: set[int] | None = None

packages/reflex-base/src/reflex_base/components/memoize_helpers.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -154,27 +154,27 @@ def fix_event_triggers_for_memo(
154154
"""Return a component whose event triggers reference memoized ``useCallback``s.
155155
156156
Replaces each (non-lifecycle) event-trigger value with a ``Var`` naming a
157-
memoized ``useCallback`` wrapper. The original is never mutated — a
158-
page-local clone is taken via ``page_context.own`` on first write.
157+
memoized ``useCallback`` wrapper. The original is never mutated — a frozen
158+
copy with the rewritten triggers is returned via ``copy_with``.
159159
160160
Args:
161161
component: The component whose event triggers to memoize.
162-
page_context: The active page context, used to obtain a page-local
163-
clone before rewriting ``event_triggers``.
162+
page_context: The active page context (unused; retained for API
163+
compatibility with downstream callers).
164164
165165
Returns:
166-
Either ``component`` (when nothing needed rewriting) or a page-local
167-
clone with the rewritten ``event_triggers``.
166+
Either ``component`` (when nothing needed rewriting) or a new frozen
167+
copy with the rewritten ``event_triggers``.
168168
"""
169169
memo_event_triggers = tuple(get_memoized_event_triggers(component).items())
170170
if not memo_event_triggers:
171171
return component
172-
owned = page_context.own(component)
173-
owned.event_triggers = {
174-
**component.event_triggers,
175-
**dict(memo_event_triggers),
176-
}
177-
return owned
172+
return component.copy_with(
173+
event_triggers={
174+
**component.event_triggers,
175+
**dict(memo_event_triggers),
176+
}
177+
)
178178

179179

180180
def is_snapshot_boundary(component: Component) -> bool:

packages/reflex-base/src/reflex_base/components/tags/iter_tag.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,6 @@ def render_component(self) -> Component:
112112

113113
# Set the component key.
114114
if component.key is None:
115-
component.key = index
115+
component = component.copy_with(key=index)
116116

117117
return component

packages/reflex-base/src/reflex_base/plugins/compiler.py

Lines changed: 6 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22

33
from __future__ import annotations
44

5-
import copy
65
import dataclasses
76
import inspect
87
from collections.abc import Callable, Sequence
98
from contextvars import ContextVar, Token
109
from types import TracebackType
11-
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, TypeVar, cast
10+
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias, cast
1211

1312
from typing_extensions import Self
1413

@@ -32,9 +31,6 @@
3231
)
3332

3433

35-
_BaseComponentT = TypeVar("_BaseComponentT", bound=BaseComponent)
36-
37-
3834
class PageDefinition(Protocol):
3935
"""Protocol for page-like objects compiled by :class:`CompileContext`."""
4036

@@ -374,8 +370,7 @@ def visit(
374370
updated_children = list(children[:index])
375371
updated_children.append(compiled_child)
376372
if updated_children is not None:
377-
current_comp = page_context.own(current_comp)
378-
current_comp.children = updated_children
373+
current_comp = current_comp.copy_with(children=tuple(updated_children))
379374

380375
if isinstance(current_comp, Component):
381376
for prop_component in current_comp._get_components_in_props():
@@ -437,8 +432,7 @@ def visit(
437432
updated_children = list(children[:index])
438433
updated_children.append(compiled_child)
439434
if updated_children is not None:
440-
current_comp = page_context.own(current_comp)
441-
current_comp.children = updated_children
435+
current_comp = current_comp.copy_with(children=tuple(updated_children))
442436

443437
if isinstance(current_comp, Component):
444438
for prop_component in current_comp._get_components_in_props():
@@ -549,8 +543,9 @@ def visit(
549543
if len(compiled_children) != len(current) or any(
550544
a is not b for a, b in zip(compiled_children, current, strict=True)
551545
):
552-
compiled_component = page_context.own(compiled_component)
553-
compiled_component.children = list(compiled_children)
546+
compiled_component = compiled_component.copy_with(
547+
children=tuple(compiled_children)
548+
)
554549
return compiled_component
555550

556551
return visit(
@@ -695,38 +690,6 @@ class PageContext(BaseContext):
695690
# the matching ``leave_component``. Non-empty iff we are inside such a
696691
# subtree.
697692
memoize_suppressor_stack: list[int] = dataclasses.field(default_factory=list)
698-
# Maps both the user-owned original's ``id()`` and the clone's ``id()`` to
699-
# the page-local clone. Lets the walker and plugins rebind children, style,
700-
# or event_triggers on a page-local copy without mutating a user-owned
701-
# instance that may be referenced from another route.
702-
_owned: dict[int, BaseComponent] = dataclasses.field(default_factory=dict)
703-
# Strong references to originals keyed by ``id()`` above. Without these,
704-
# an original that is only reachable through ``_owned``'s int key can be
705-
# garbage collected, and Python may recycle its ``id()`` for a fresh
706-
# component, causing ``own()`` to hand back the wrong clone.
707-
_owned_refs: list[BaseComponent] = dataclasses.field(default_factory=list)
708-
709-
def own(self, comp: _BaseComponentT) -> _BaseComponentT:
710-
"""Return a page-local copy of ``comp``, cloning on first encounter.
711-
712-
Repeated calls with the same original return the same clone, so
713-
mutations from several plugins accumulate on one instance.
714-
715-
Args:
716-
comp: The component the caller is about to mutate.
717-
718-
Returns:
719-
A component the caller may freely mutate without touching any
720-
user-owned instance.
721-
"""
722-
existing = self._owned.get(id(comp))
723-
if existing is not None:
724-
return cast("_BaseComponentT", existing)
725-
new = copy.copy(comp)
726-
self._owned[id(comp)] = new
727-
self._owned[id(new)] = new
728-
self._owned_refs.append(comp)
729-
return new
730693

731694
def merged_imports(self, *, collapse: bool = False) -> ParsedImportDict:
732695
"""Return the imports accumulated for this page.

0 commit comments

Comments
 (0)