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.
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]
Comment on lines +1233 to +1234

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route stored descriptor props through their getter

When a custom Component overrides an inherited JavaScript field with a property or data descriptor whose setter stores the raw value under the same name in __dict__, this branch returns that raw value and bypasses the descriptor getter. The previous unconditional getattr(self, prop) returned the getter's transformed value, so rendering, Var collection, and component-in-prop discovery can now all use the wrong value; retain descriptor-aware access for such class attributes while keeping the direct-dict fast path for ordinary fields.

AGENTS.md reference: AGENTS.md:L43-L43

Useful? React with 👍 / 👎.

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

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 a child renderer returns a dict subclass or another value handled specially by render_prop, the plain-Tag fast path bypasses that protocol and changes the rendered children. Apply render_prop to children here, matching Tag.__iter__ and preserving the no-output-change contract.

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/components/component.py, line 1480:

<comment>When a child renderer returns a `dict` subclass or another value handled specially by `render_prop`, the plain-`Tag` fast path bypasses that protocol and changes the rendered children. Apply `render_prop` to `children` here, matching `Tag.__iter__` and preserving the no-output-change contract.</comment>

<file context>
@@ -1438,11 +1471,15 @@ def render(self) -> 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))
</file context>
Suggested change
rendered_dict["children"] = children
rendered_dict["children"] = render_prop(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
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
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]
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
return literal_subclass
Comment on lines +131 to +146

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 a literal-supported value has an unhashable metaclass, type(value) is unhashable and this lookup raises TypeError before literal dispatch runs. Catch TypeError during lookup and skip caching unhashable types.

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 131:

<comment>When a literal-supported value has an unhashable metaclass, `type(value)` is unhashable and this lookup raises `TypeError` before literal dispatch runs. Catch `TypeError` during lookup and skip caching unhashable types.</comment>

<file context>
@@ -114,6 +113,37 @@ class VarSubclassEntry:
+        The matching literal class, or None if no registered class claims it.
+    """
+    value_type = type(value)
+    try:
+        return _literal_var_by_type[value_type]
+    except KeyError:
</file context>
Suggested change
try:
return _literal_var_by_type[value_type]
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
return literal_subclass
try:
return _literal_var_by_type[value_type]
except (KeyError, TypeError):
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):
try:
_literal_var_by_type[value_type] = literal_subclass
except TypeError:
pass
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
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 Missing root changelog fragment

This changes source in the root reflex package, but the only added news fragment belongs to reflex-base. The repository requires a news fragment for every package whose source is touched, so root-package changelog handling must be added or explicitly waived before merging.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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-package news fragment

This modifies the main reflex package by changing reflex/compiler/plugins/memoize.py, but the commit adds a fragment only under packages/reflex-base/news/. Add a corresponding root news/ performance fragment; otherwise the repository's per-package changelog requirement is not satisfied.

AGENTS.md reference: AGENTS.md:L96-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
49 changes: 49 additions & 0 deletions tests/units/components/test_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import pytest
from reflex_base.components.component import Component, field
from reflex_base.components.tags import Tag
from reflex_base.constants import EventTriggers
from reflex_base.constants.state import FIELD_MARKER
from reflex_base.event import (
Expand Down Expand Up @@ -45,6 +46,33 @@
from reflex.utils import imports


@pytest.mark.parametrize("name", ["div", "", None])
def test_plain_tag_render_matches_tag_protocol(name, monkeypatch):
"""Direct rendering preserves names, props, children, and render caching."""
tag = Tag(name=name).add_props(title="hello")
component = Component._create(children=[Bare.create("child")])
monkeypatch.setattr(component, "_render", lambda: tag)
expected = dict(tag.set(children=[child.render() for child in component.children]))
assert component.render() == expected
assert component.render() is component.render()
assert not tag.children


def test_custom_tag_render_uses_subclass_protocol(monkeypatch):
"""Custom tag iteration can depend on its supplied children."""

class ChildrenTag(Tag):
"""A tag with custom child-dependent rendering."""

def __iter__(self):
"""Yield a value derived from the child list."""
yield "child_count", len(self.children)

component = Component._create(children=[Bare.create("child")])
monkeypatch.setattr(component, "_render", lambda: ChildrenTag())
assert component.render() == {"child_count": 1}


class TestState(BaseState):
"""A test state with various methods for event handling."""

Expand Down Expand Up @@ -2398,3 +2426,24 @@ def test_get_all_hooks_internal_does_not_mutate_hooks_cache():
assert dict(parent._get_hooks_internal()) == parent_own_hooks
# And repeated collection yields the same result.
assert parent._get_all_hooks_internal() == combined


def test_set_props_iteration_skips_unset_props_and_keeps_defaults():
"""Only set props and class defaults are visited, in declaration order."""

class DefaultedProps(Component):
first: Var[str]
second: Var[str] = LiteralVar.create("second-default")
third: Var[str]

component = DefaultedProps._create(children=(), third="set")
assert [(prop, str(value)) for prop, value in component._iter_set_props()] == [
("second", '"second-default"'),
("third", '"set"'),
]
assert [str(var) for var in component._get_vars()] == ['"second-default"', '"set"']
assert {prop: str(value) for prop, value in component._render().props.items()} == {
"second": '"second-default"',
"third": '"set"',
}
assert "first" not in vars(component)
26 changes: 26 additions & 0 deletions tests/units/components/test_tag.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest
from reflex_base.components.tags import CondTag, Tag, tagless
from reflex_base.components.tags.tag import render_prop
from reflex_base.vars.base import LiteralVar, Var


Expand Down Expand Up @@ -127,3 +128,28 @@ def test_tagless_string_representation():
tag = tagless.Tagless(contents="Hello world")
expected_output = "Hello world"
assert str(tag) == expected_output


def test_render_prop_preserves_plain_values_and_subclass_dispatch():
"""Already-rendered dictionaries pass through; callable subclasses do not."""

class CallableString(str):
"""A string whose callability must still be inspected."""

def __call__(self):
"""Return a marker value."""
return "called"

class CallableDict(dict):
"""A mapping whose callability must still be inspected."""

def __call__(self):
"""Return a marker value."""
return "called"

rendered = {"name": "div", "children": []}
assert render_prop(rendered) is rendered
assert render_prop("text") == "text"
assert render_prop(CallableString("text")) is None
assert render_prop(CallableDict(rendered)) is None
assert render_prop(("text", rendered)) == ["text", rendered]
Loading