Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Speed up compilation by reading only the props a component sets, caching literal Var dispatch by value type, and trimming render and app-wrap bookkeeping.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Share one event chain per handler and trigger across call sites, and reuse memoized event wrappers by chain identity during compilation.
58 changes: 47 additions & 11 deletions packages/reflex-base/src/reflex_base/components/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from reflex_base.components.dynamic import load_dynamic_serializer
from reflex_base.components.field import BaseField, FieldBasedMeta
from reflex_base.components.tags import Tag
from reflex_base.components.tags.tag import render_prop
from reflex_base.constants import Dirs, EventTriggers, Hooks, Imports, MemoizationMode
from reflex_base.constants.compiler import SpecialAttributes
from reflex_base.event import (
Expand Down Expand Up @@ -1161,7 +1162,7 @@ def _render(self, props: dict[str, Any] | None = None) -> Tag:
if props is None:
# Add component props to the tag.
props = {
attr.removesuffix("_"): getattr(self, attr) for attr in self.get_props()
prop.removesuffix("_"): value for prop, value in self._iter_set_props()
}

# Add ref to element if `ref` is None and `id` is not None.
Expand Down Expand Up @@ -1201,6 +1202,39 @@ def get_props(cls) -> Iterable[str]:
"""
return cls.get_js_fields()

@classmethod
@functools.cache
def _get_defaulted_props(cls) -> frozenset[str]:
"""Get the props whose field supplies a value when unset.

Returns:
The props with a default other than ``None`` or a default factory.
"""
return frozenset(
prop
for prop, field_ in cls.get_js_fields().items()
if field_.default_factory is not None
or (field_.default is not MISSING and field_.default is not None)
)

def _iter_set_props(self) -> Iterator[tuple[str, Any]]:
"""Walk the props that carry a value, in declaration order.

An unset prop resolves to ``None`` through its field descriptor and
every consumer drops ``None``, so only props present on the instance
or backed by a class default are read.

Yields:
Each prop name with its value.
"""
values = self.__dict__
defaulted = self._get_defaulted_props()
for prop in self.get_props():
if prop in values:
yield prop, values[prop]
elif prop in defaulted:
yield prop, getattr(self, prop)

@classmethod
@functools.cache
def get_initial_props(cls) -> set[str]:
Expand All @@ -1215,9 +1249,8 @@ def get_initial_props(cls) -> set[str]:
def _get_component_prop_property(self) -> Sequence[BaseComponent]:
return [
component
for prop in self.get_props()
if (value := getattr(self, prop)) is not None
and isinstance(value, (BaseComponent, Var))
for _, value in self._iter_set_props()
if isinstance(value, (BaseComponent, Var))
for component in _components_from(value)
]

Expand Down Expand Up @@ -1438,11 +1471,15 @@ def render(self) -> dict:
except AttributeError:
pass
tag = self._render()
rendered_dict = dict(
tag.set(
children=[child.render() for child in self.children],
)
)
children = [child.render() for child in self.children]
if type(tag) is Tag:
rendered_dict = {}
if (name := render_prop(tag.name)) is not None:
rendered_dict["name"] = name
rendered_dict["props"] = tag.format_props()
rendered_dict["children"] = children
else:
rendered_dict = dict(tag.set(children=children))
self._replace_prop_names(rendered_dict)
self._cached_render_result = rendered_dict
return rendered_dict
Expand Down Expand Up @@ -1581,8 +1618,7 @@ def _get_vars(
vars.extend(event_vars)

# Get Vars associated with component props.
for prop in self.get_props():
prop_var = getattr(self, prop)
for _, prop_var in self._iter_set_props():
if isinstance(prop_var, Var):
vars.append(prop_var)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from reflex_base.components.component import BaseComponent, Component
from reflex_base.constants import EventTriggers
from reflex_base.event import EventChain, EventSpec
from reflex_base.registry import RegistrationContext
from reflex_base.utils.imports import ImportVar
from reflex_base.vars import VarData
from reflex_base.vars.base import LiteralVar, Var
Expand Down Expand Up @@ -100,6 +101,9 @@ def get_memoized_event_triggers(
A dict mapping event trigger name to memoized_triger.
"""
trigger_memo: dict[str, Var] = {}
if not component.event_triggers:
return trigger_memo
cache = RegistrationContext.ensure_context()._memoized_event_triggers
for event_trigger, event_args in component._get_vars_from_event_triggers(
component.event_triggers
):
Expand All @@ -112,8 +116,17 @@ def get_memoized_event_triggers(
continue

event = component.event_triggers[event_trigger]
rendered_chain = LiteralVar.create(event)
cache_key = (event_trigger, id(event))
cached = cache.get(cache_key)
if cached is not None and cached[0] is event:
trigger_memo[event_trigger] = cached[1]
continue

rendered_chain = LiteralVar.create(event)
rendered_data = rendered_chain._get_all_var_data()
event_var_data = [
data for arg in event_args if (data := arg._get_all_var_data()) is not None
]
chain_hash = md5(
str(rendered_chain).encode("utf-8"), usedforsecurity=False
).hexdigest()
Expand All @@ -122,18 +135,13 @@ def get_memoized_event_triggers(
var_deps = ["addEvents", "ReflexEvent"]
var_deps.extend(_get_deps_from_event_trigger(event))

event_var_data = []
for arg in event_args:
var_data = arg._get_all_var_data()
if var_data is None:
continue
event_var_data.append(var_data)
for var_data in event_var_data:
for hook in var_data.hooks:
var_deps.extend(_get_hook_deps(hook))

memo_var_data = VarData.merge(
*event_var_data,
rendered_chain._get_all_var_data(),
rendered_data,
VarData(
hooks=[
f"const {memo_name} = useCallback({rendered_chain!s}, [{', '.join(var_deps)}])"
Expand All @@ -142,9 +150,11 @@ def get_memoized_event_triggers(
),
)

trigger_memo[event_trigger] = Var(
trigger_memo[event_trigger] = memo_var = Var(
_js_expr=memo_name, _var_type=EventChain, _var_data=memo_var_data
)
# Hold the chain so its id cannot be recycled while the entry lives.
cache[cache_key] = event, memo_var
return trigger_memo


Expand Down
3 changes: 3 additions & 0 deletions packages/reflex-base/src/reflex_base/components/tags/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def render_prop(value: Any) -> Any:
Returns:
The rendered value.
"""
if type(value) in (str, dict):
return value

from reflex_base.components.component import BaseComponent

if isinstance(value, BaseComponent):
Expand Down
16 changes: 14 additions & 2 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,16 @@ def create(
# Trust that the caller knows what they're doing passing an EventChain directly
return value

# A handler bound to one trigger always produces the same chain, so
# every call site sharing the handler shares one instance. The cache
# lives on the handler, which is never pickled or copied.
bound_chains = None
if not event_chain_kwargs and isinstance(value, EventHandler):
bound_chains = value.__dict__.setdefault("_bound_chains", {})
bound = bound_chains.get((id(args_spec), key))
if bound is not None and bound[0] is args_spec:
return bound[1]

# If the input is a single event handler, wrap it in a list.
if isinstance(value, (EventHandler, EventSpec)):
value = [value]
Expand Down Expand Up @@ -952,12 +962,14 @@ def create(
for e in events
]

# Return the event chain.
return cls(
chain = cls(
events=events,
args_spec=args_spec,
**event_chain_kwargs,
)
if bound_chains is not None:
bound_chains[id(args_spec), key] = args_spec, chain
return chain


@dataclasses.dataclass(
Expand Down
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from reflex.state import BaseState
from reflex_base.config import Config
from reflex_base.event import EventHandler
from reflex_base.vars.base import Var


def _default_bundled_libraries() -> list[str]:
Expand Down Expand Up @@ -69,6 +70,9 @@ class RegistrationContext(BaseContext):
repr=False,
)
_app: App | None = dataclasses.field(default=None, repr=False)
_memoized_event_triggers: dict[tuple[str, int], tuple[Any, Var]] = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new _memoized_event_triggers cache grows unboundedly over the lifetime of the process-wide RegistrationContext and is never invalidated. Each compile/recompile memoizes chains under fresh id(event) keys and holds strong references to the chain and its memo Var; nothing clears the dict (fork() omits it and clear_hash_caches() doesn't touch it), so dev-server reloads accumulate stale entries and retained VarData/memo vars across the app lifetime. Add an invalidation point (e.g. clear it in the same place clear_hash_caches() runs on compile, or on fork()), or bound entries that no longer match the live chain.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/registry.py, line 73:

<comment>The new `_memoized_event_triggers` cache grows unboundedly over the lifetime of the process-wide RegistrationContext and is never invalidated. Each compile/recompile memoizes chains under fresh `id(event)` keys and holds strong references to the chain and its memo Var; nothing clears the dict (`fork()` omits it and `clear_hash_caches()` doesn't touch it), so dev-server reloads accumulate stale entries and retained VarData/memo vars across the app lifetime. Add an invalidation point (e.g. clear it in the same place `clear_hash_caches()` runs on compile, or on `fork()`), or bound entries that no longer match the live chain.</comment>

<file context>
@@ -69,6 +70,9 @@ class RegistrationContext(BaseContext):
         repr=False,
     )
     _app: App | None = dataclasses.field(default=None, repr=False)
+    _memoized_event_triggers: dict[tuple[str, int], tuple[Any, Var]] = (
+        dataclasses.field(default_factory=dict, repr=False)
+    )
</file context>

dataclasses.field(default_factory=dict, repr=False)
)

@property
def app(self) -> App:
Expand Down
52 changes: 41 additions & 11 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import logging
import re
import string
import uuid
import warnings
from abc import ABCMeta
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
Expand Down Expand Up @@ -114,6 +113,37 @@ class VarSubclassEntry:

_var_subclasses: list[VarSubclassEntry] = []
_var_literal_subclasses: list[tuple[type[LiteralVar], VarSubclassEntry]] = []
# Exact value type -> the literal class claiming it, or None when no literal
# class does. Reset whenever a literal subclass registers.
_literal_var_by_type: dict[type, type[LiteralVar] | None] = {}


def _literal_var_for(value: Any) -> type[LiteralVar] | None:
"""Find the literal Var class claiming ``value``'s type.

Args:
value: The python value to wrap.

Returns:
The matching literal class, or None if no registered class claims it.
"""
value_type = type(value)
try:
return _literal_var_by_type[value_type]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an ABC-backed python_types registration changes after the first lookup, this returns a stale literal class for that concrete type. Avoid caching checks whose isinstance result can change, or provide invalidation for ABC/protocol registrations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 132:

<comment>When an ABC-backed `python_types` registration changes after the first lookup, this returns a stale literal class for that concrete type. Avoid caching checks whose `isinstance` result can change, or provide invalidation for ABC/protocol registrations.</comment>

<file context>
@@ -114,6 +113,37 @@ class VarSubclassEntry:
+    """
+    value_type = type(value)
+    try:
+        return _literal_var_by_type[value_type]
+    except KeyError:
+        pass
</file context>

except KeyError:
pass
literal_subclass = next(
(
literal
for literal, var_subclass in reversed(_var_literal_subclasses)
if isinstance(value, var_subclass.python_types)
),
None,
)
# A class object's type is its metaclass, which other classes share.
if not isinstance(value, type):
_literal_var_by_type[value_type] = literal_subclass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Each new concrete value type is retained permanently by this global cache, including types with no literal handler. Use weak type keys or another bounded/lifecycle-aware cache so dynamically generated classes do not accumulate during a long-lived compile process.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 145:

<comment>Each new concrete value type is retained permanently by this global cache, including types with no literal handler. Use weak type keys or another bounded/lifecycle-aware cache so dynamically generated classes do not accumulate during a long-lived compile process.</comment>

<file context>
@@ -114,6 +113,37 @@ class VarSubclassEntry:
+    )
+    # A class object's type is its metaclass, which other classes share.
+    if not isinstance(value, type):
+        _literal_var_by_type[value_type] = literal_subclass
+    return literal_subclass
 
</file context>

return literal_subclass


@functools.cache
Expand Down Expand Up @@ -235,7 +265,7 @@ def insert_app_wraps(
if seen is None:
seen = target.get(key)
if seen is not None:
if seen != wrapper:
if seen is not wrapper and seen != wrapper:
msg = (
f"Conflicting app wraps for {key!r}: two different "
"components claim the same (priority, tag) slot."
Expand Down Expand Up @@ -1650,6 +1680,7 @@ def __init_subclass__(cls, **kwargs):
_var_literal_subclasses.remove(var_literal_subclass)

_var_literal_subclasses.append((cls, var_subclass))
_literal_var_by_type.clear()

@classmethod
def _create_literal_var(
Expand Down Expand Up @@ -1677,9 +1708,8 @@ def _create_literal_var(
return value
return value._replace(merge_var_data=_var_data)

for literal_subclass, var_subclass in _var_literal_subclasses[::-1]:
if isinstance(value, var_subclass.python_types):
return literal_subclass.create(value, _var_data=_var_data)
if (literal_subclass := _literal_var_for(value)) is not None:
return literal_subclass.create(value, _var_data=_var_data)

if (
(as_var_method := getattr(value, "_as_var", None)) is not None
Expand Down Expand Up @@ -1759,9 +1789,8 @@ def _get_all_var_data_without_creating_var_dispatch(
if isinstance(value, Var):
return value._get_all_var_data()

for literal_subclass, var_subclass in _var_literal_subclasses[::-1]:
if isinstance(value, var_subclass.python_types):
return literal_subclass._get_all_var_data_without_creating_var(value)
if (literal_subclass := _literal_var_for(value)) is not None:
return literal_subclass._get_all_var_data_without_creating_var(value)

if (
(as_var_method := getattr(value, "_as_var", None)) is not None
Expand Down Expand Up @@ -2019,6 +2048,8 @@ def __set_name__(self, owner: Any, name: str):
"""
if self._attrname is None:
self._attrname = name
self._cached_field_name = "_reflex_cache_" + name
cached_field_name = self._cached_field_name

original_del = getattr(owner, "__del__", None)

Expand All @@ -2028,7 +2059,6 @@ def delete_property(this: Any):
Args:
this: The object to delete the cached property from.
"""
cached_field_name = "_reflex_cache_" + name
try:
unique_id = object.__getattribute__(this, cached_field_name)
except AttributeError:
Expand Down Expand Up @@ -2065,11 +2095,11 @@ def __get__(self, instance: Any, owner: type | None = None):
if self._attrname is None:
msg = "Cannot use cached_property on a class without __set_name__."
raise TypeError(msg)
cached_field_name = "_reflex_cache_" + self._attrname
cached_field_name = self._cached_field_name
try:
unique_id = object.__getattribute__(instance, cached_field_name)
except AttributeError:
unique_id = uuid.uuid4().int
unique_id = object()
object.__setattr__(instance, cached_field_name, unique_id)
if unique_id not in GLOBAL_CACHE:
GLOBAL_CACHE[unique_id] = self._func(instance)
Expand Down
7 changes: 3 additions & 4 deletions reflex/compiler/plugins/memoize.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
from reflex_base.constants.compiler import MemoizationDisposition

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Add a root news/+<slug>.performance.md fragment for the changed reflex package, or apply the documented skip-changelog waiver; otherwise the changelog check fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At reflex/compiler/plugins/memoize.py, line 38:

<comment>Add a root `news/+<slug>.performance.md` fragment for the changed `reflex` package, or apply the documented `skip-changelog` waiver; otherwise the changelog check fails.</comment>

<file context>
@@ -35,6 +35,9 @@
 from reflex_base.constants.compiler import MemoizationDisposition
 from reflex_base.plugins import ComponentAndChildren, PageContext
 from reflex_base.plugins.base import Plugin
+from reflex_components_core.base.bare import Bare
+from reflex_components_core.core.cond import Cond
+from reflex_components_core.core.match import Match
</file context>

from reflex_base.plugins import ComponentAndChildren, PageContext
from reflex_base.plugins.base import Plugin
from reflex_components_core.base.bare import Bare
from reflex_components_core.core.cond import Cond
from reflex_components_core.core.match import Match
Comment on lines +38 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Root News Fragment Missing

This changes production code in the root reflex package, but the PR only adds news fragments under packages/reflex-base/news. The repository requires a news fragment for every package whose source is changed. Add a root news/ performance fragment or apply the documented skip-changelog waiver before merging.

Context Used: CLAUDE.md (source)

Comment on lines +38 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required root Reflex news fragment

This commit changes packaged source under reflex/, but both new fragments are under packages/reflex-base/news/. On a PR without the skip-changelog label, the changelog workflow treats reflex and reflex-base as affected packages and the root-package check will fail because news/ has no fragment for this change. Add a corresponding root news/+<slug>.performance.md fragment.

AGENTS.md reference: AGENTS.md:L94-L100

Useful? React with 👍 / 👎.


from reflex.compiler.plugins.builtin import (
collect_var_app_wraps_for_component,
Expand Down Expand Up @@ -146,10 +149,6 @@ def _should_memoize(component: Component) -> bool:
Returns:
True if the component should be wrapped in a memo definition.
"""
from reflex_components_core.base.bare import Bare
from reflex_components_core.core.cond import Cond
from reflex_components_core.core.match import Match

strategy = get_memoization_strategy(component)

if component._memoization_mode.disposition == MemoizationDisposition.NEVER:
Expand Down
Loading