From b46f2e20cb6dc8e32b75d8678552bfce09c63419 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Wed, 13 May 2026 23:43:09 +0500 Subject: [PATCH 1/7] refactor: promote memo out of experimental into rx.memo Move the memo/custom-component machinery from ``reflex/experimental/memo.py`` and ``reflex_base.components.component`` into a dedicated ``reflex_base.components.memo`` module and expose it as ``rx.memo``. ``rx.experimental.memo`` becomes a deprecated alias, and the legacy ``CustomComponent`` index path in the compiler is dropped now that all memos declare their library per-file. --- .../reflex_docs/templates/docpage/docpage.py | 4 +- docs/enterprise/drag-and-drop.md | 2 +- docs/library/other/memo.md | 187 +++++----- .../src/reflex_base/components/component.py | 324 +----------------- .../src/reflex_base/components}/memo.py | 25 +- .../src/reflex_base/plugins/compiler.py | 5 +- .../components/base/skeleton.py | 5 +- .../components/base/theme_switcher.py | 6 +- .../components/icons/others.py | 7 +- .../reflex_components_markdown/markdown.py | 11 +- .../components/blocks/code.py | 4 +- .../components/blocks/headings.py | 18 +- .../components/code_card.py | 4 +- .../src/reflex_site_shared/views/footer.py | 4 +- pyi_hashes.json | 4 +- reflex/__init__.py | 2 +- reflex/app.py | 8 +- reflex/compiler/compiler.py | 65 +--- reflex/compiler/plugins/memoize.py | 3 +- reflex/compiler/utils.py | 59 +--- reflex/components/memo.py | 4 + reflex/experimental/__init__.py | 14 +- reflex/testing.py | 7 +- tests/integration/test_memo.py | 106 ------ tests/integration/test_var_operations.py | 16 +- tests/units/compiler/test_memoize_plugin.py | 18 +- .../components/markdown/test_markdown.py | 5 +- tests/units/components/test_component.py | 106 +----- tests/units/conftest.py | 8 +- tests/units/experimental/test_memo.py | 47 ++- tests/units/test_testing.py | 7 +- 31 files changed, 236 insertions(+), 849 deletions(-) rename {reflex/experimental => packages/reflex-base/src/reflex_base/components}/memo.py (98%) create mode 100644 reflex/components/memo.py delete mode 100644 tests/integration/test_memo.py diff --git a/docs/app/reflex_docs/templates/docpage/docpage.py b/docs/app/reflex_docs/templates/docpage/docpage.py index 46880da6d8f..a27e0f7a7fb 100644 --- a/docs/app/reflex_docs/templates/docpage/docpage.py +++ b/docs/app/reflex_docs/templates/docpage/docpage.py @@ -223,7 +223,7 @@ def feedback_button_toc() -> rx.Component: @rx.memo -def copy_to_markdown(text: str) -> rx.Component: +def copy_to_markdown(text: rx.Var[str]) -> rx.Component: copied = ClientStateVar.create("is_copied", default=False, global_ref=False) return marketing_button( rx.cond( @@ -270,7 +270,7 @@ def link_pill(text: str, href: str) -> rx.Component: @rx.memo -def docpage_footer(path: str): +def docpage_footer(path: rx.Var[str]) -> rx.Component: from reflex_site_shared.constants import FORUM_URL, ROADMAP_URL return rx.el.footer( diff --git a/docs/enterprise/drag-and-drop.md b/docs/enterprise/drag-and-drop.md index 58c59243c54..b793ff7095e 100644 --- a/docs/enterprise/drag-and-drop.md +++ b/docs/enterprise/drag-and-drop.md @@ -274,7 +274,7 @@ class DynamicListState(rx.State): @rx.memo -def draggable_list_item(item: ListItem): +def draggable_list_item(item: rx.Var[ListItem]) -> rx.Component: return rxe.dnd.draggable( rx.card( rx.text(item.text, weight="bold"), diff --git a/docs/library/other/memo.md b/docs/library/other/memo.md index f108d34a6a2..75b9286c9f4 100644 --- a/docs/library/other/memo.md +++ b/docs/library/other/memo.md @@ -4,21 +4,22 @@ import reflex as rx # Memo -The `memo` decorator is used to optimize component rendering by memoizing components that don't need to be re-rendered. This is particularly useful for expensive components that depend on specific props and don't need to be re-rendered when other state changes in your application. +The `@rx.memo` decorator turns a function into a memoized React component. The compiler emits the function as its own module, and React's `memo` only re-renders it when its declared props change. Reach for it when a subtree is expensive to render and depends on a narrow slice of state. ## Requirements -When using `rx.memo`, you must follow these requirements: +Every parameter must be annotated with `rx.Var[...]` or `rx.RestProp`. The compiler reads those annotations to generate prop names, prop forwarding, and the JS function signature. -1. **Type all arguments**: All arguments to a memoized component must have type annotations. -2. **Use keyword arguments**: When calling a memoized component, you must use keyword arguments (not positional arguments). +1. **`rx.Var[T]` for props** — annotate each prop as `rx.Var[T]` where `T` is the prop's runtime type (`str`, `int`, a TypedDict, etc.). Inside the function body, the parameter is a `Var` you compose into the rendered tree. +2. **`rx.RestProp` for spread props** — at most one parameter may be annotated as `rx.RestProp`, which forwards unrecognized kwargs through to the rendered root. +3. **`rx.Var[rx.Component]` for slot children** — a parameter named `children` annotated as `rx.Var[rx.Component]` accepts children rendered by the caller. +4. **Keyword arguments at the call site** — pass props by name, not by position. -## Basic Usage +Defaults work normally: `class_name: rx.Var[str] = ""` falls back to `""` when the caller omits the prop. -When you wrap a component function with `@rx.memo`, the component will only re-render when its props change. This helps improve performance by preventing unnecessary re-renders. +## Basic Usage ```python -# Define a state class to track count class DemoState(rx.State): count: int = 0 @@ -27,150 +28,148 @@ class DemoState(rx.State): self.count += 1 -# Define a memoized component @rx.memo -def expensive_component(label: str) -> rx.Component: +def expensive_component(label: rx.Var[str]) -> rx.Component: return rx.vstack( rx.heading(label), - rx.text("This component only re-renders when props change!"), + rx.text("This component only re-renders when props change."), rx.divider(), ) -# Use the memoized component in your app def index(): return rx.vstack( - rx.heading("Memo Example"), - rx.text("Count: 0"), # This will update with state.count + rx.text(f"Count: {DemoState.count}"), rx.button("Increment", on_click=DemoState.increment), - rx.divider(), - expensive_component(label="Memoized Component"), # Must use keyword arguments - spacing="4", - padding="4", - border_radius="md", - border="1px solid #eaeaea", + expensive_component(label="Memoized Component"), ) ``` -In this example, the `expensive_component` will only re-render when the `label` prop changes, not when the `count` state changes. +`expensive_component` re-renders only when `label` changes — bumping `DemoState.count` does not invalidate it. -## With Event Handlers +## With State Variables -You can also use `rx.memo` with components that have event handlers: +Props can be ordinary Vars. The memoized component re-renders when those Vars change: ```python -# Define a state class to track clicks -class ButtonState(rx.State): - clicks: int = 0 - - @rx.event - def increment(self): - self.clicks += 1 +class AppState(rx.State): + name: str = "World" -# Define a memoized button component @rx.memo -def my_button(text: str, on_click: rx.EventHandler) -> rx.Component: - return rx.button(text, on_click=on_click) +def greeting(name: rx.Var[str]) -> rx.Component: + return rx.heading("Hello, " + name) -# Use the memoized button in your app def index(): return rx.vstack( - rx.text("Clicks: 0"), # This will update with state.clicks - my_button(text="Click me", on_click=ButtonState.increment), - spacing="4", + greeting(name=AppState.name), + rx.input(value=AppState.name, on_change=AppState.set_name), ) ``` -## With State Variables +## Forwarding Props with `rx.RestProp` -When used with state variables, memoized components will only re-render when the specific state variables they depend on change: +Use `rx.RestProp` to accept and forward arbitrary props (think `...rest` in JSX). Useful for thin wrappers that re-style a primitive without re-declaring every prop. ```python -# Define a state class with multiple variables -class AppState(rx.State): - name: str = "World" - count: int = 0 +@rx.memo +def primary_button( + rest: rx.RestProp, + *, + label: rx.Var[str], +) -> rx.Component: + return rx.button(label, class_name="bg-primary-9 text-white", **rest) - @rx.event - def increment(self): - self.count += 1 - @rx.event - def set_name(self, name: str): - self.name = name +def index(): + return primary_button( + label="Save", + on_click=rx.console_log("clicked"), + id="save", + ) +``` +At most one `rx.RestProp` parameter is allowed per memo. -# Define a memoized greeting component +## Accepting Children + +Declare a parameter named `children` typed as `rx.Var[rx.Component]` to receive a child subtree. + +```python @rx.memo -def greeting(name: str) -> rx.Component: - return rx.heading("Hello, " + name) # Will display the name prop +def card( + children: rx.Var[rx.Component], + *, + title: rx.Var[str], +) -> rx.Component: + return rx.box( + rx.heading(title), + children, + class_name="border border-slate-5 rounded-lg p-4", + ) -# Use the memoized component with state variables def index(): - return rx.vstack( - greeting(name=AppState.name), # Must use keyword arguments - rx.text("Count: 0"), # Will display the count - rx.button("Increment Count", on_click=AppState.increment), - rx.input( - placeholder="Enter your name", - on_change=AppState.set_name, - value="World", # Will be bound to AppState.name - ), - spacing="4", + return card( + rx.text("Body copy goes here."), + title="Memoized card", ) ``` -## Advanced Event Handler Example +## Returning a `Var` Instead of a Component -You can also pass arguments to event handlers in memoized components: +A memo function can return `rx.Var[T]` instead of `rx.Component`. The compiler emits a plain JavaScript function and the call site is just a `Var` you can compose into the page. ```python -# Define a state class to track messages -class MessageState(rx.State): - message: str = "" - - @rx.event - def set_message(self, text: str): - self.message = text +class PriceState(rx.State): + amount: int = 100 + currency: str = "USD" -# Define a memoized component with event handlers that pass arguments @rx.memo -def action_buttons( - on_action: rx.EventHandler[rx.event.passthrough_event_spec(str)], -) -> rx.Component: - return rx.hstack( - rx.button("Save", on_click=on_action("Saved!")), - rx.button("Delete", on_click=on_action("Deleted!")), - rx.button("Cancel", on_click=on_action("Cancelled!")), - spacing="2", - ) +def format_price(amount: rx.Var[int], currency: rx.Var[str]) -> rx.Var[str]: + return currency.to(str) + ": $" + amount.to(str) -# Use the memoized component with event handlers def index(): + formatted = format_price(amount=PriceState.amount, currency=PriceState.currency) return rx.vstack( - rx.text("Status: "), # Will display the message - action_buttons(on_action=MessageState.set_message), - spacing="4", + rx.text(formatted), ) ``` +The body of a `Var`-returning memo runs at compile time and is restricted to Var operations — no hooks, no Python branching on the Vars. + ## Performance Considerations -Use `rx.memo` for: +Reach for `rx.memo` when: -- Components with expensive rendering logic -- Components that render the same result given the same props -- Components that re-render too often due to parent component updates +- The component is expensive to render. +- Its output is a stable function of a small set of props. +- A frequently-updating ancestor would otherwise force it to re-render. -Avoid using `rx.memo` for: +Skip it when: + +- The component is cheap and the bookkeeping is not worth it. +- The props change on every render anyway — memo never gets to short-circuit. + +## Migrating from the Old `rx.memo` + +The previous `rx.memo` accepted plain-typed arguments (`def card(title: str)`). The new one requires `rx.Var[...]`. To migrate: + +```python +# Before +@rx.memo +def card(title: str) -> rx.Component: ... + + +# After +@rx.memo +def card(title: rx.Var[str]) -> rx.Component: ... +``` -- Simple components where the memoization overhead might exceed the performance gain -- Components that almost always receive different props on re-render +The old `rx._x.memo` alias still resolves to the new memo and prints a one-time `was promoted to rx.memo` notice. ## API Reference @@ -180,8 +179,8 @@ Avoid using `rx.memo` for: rx.memo(component_fn) ``` -Decorates a function that returns a Reflex component so it can be reused as a memoized component. The function arguments must be type annotated, and memoized components should be called with keyword arguments. +Wraps a function whose parameters are all `rx.Var[...]` or `rx.RestProp`. Returns a callable that constructs the memoized component (or a `Var` if the function's return annotation is `rx.Var[T]`). | Argument | Type | Description | | --- | --- | --- | -| `component_fn` | `Callable[..., rx.Component]` | Function that returns the component to memoize. | +| `component_fn` | `Callable[..., rx.Component \| rx.Var]` | The function to memoize. All parameters must be `rx.Var[...]` or `rx.RestProp`. | diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 2605e2f2315..9f5672edc17 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -6,16 +6,14 @@ import dataclasses import enum import functools -import inspect import operator import typing from abc import ABC, ABCMeta, abstractmethod from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from dataclasses import _MISSING_TYPE, MISSING -from functools import wraps from hashlib import md5 from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast, get_args, get_origin +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast from rich.markup import escape from typing_extensions import dataclass_transform @@ -27,18 +25,13 @@ from reflex_base.components.tags import Tag from reflex_base.constants import Dirs, EventTriggers, Hooks, Imports, MemoizationMode from reflex_base.constants.compiler import SpecialAttributes -from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER from reflex_base.event import ( EventCallback, EventChain, - EventHandler, EventSpec, args_specs_from_fields, no_args_event_spec, - parse_args_spec, pointer_event_spec, - run_script, - unwrap_var_annotation, ) from reflex_base.style import Style, format_as_emotion from reflex_base.utils import console, format, imports, types @@ -51,11 +44,7 @@ Var, cached_property_no_lock, ) -from reflex_base.vars.function import ( - ArgsFunctionOperation, - FunctionStringVar, - FunctionVar, -) +from reflex_base.vars.function import ArgsFunctionOperation, FunctionStringVar from reflex_base.vars.number import ternary_operation from reflex_base.vars.object import ObjectVar from reflex_base.vars.sequence import LiteralArrayVar, LiteralStringVar, StringVar @@ -2143,315 +2132,6 @@ def _get_all_app_wrap_components( return components -class CustomComponent(Component): - """A custom user-defined component.""" - - # Use the components library. - library = f"$/{Dirs.COMPONENTS_PATH}" - - component_fn: Callable[..., Component] = field( - doc="The function that creates the component.", default=Component.create - ) - - props: dict[str, Any] = field( - doc="The props of the component.", default_factory=dict - ) - - def _post_init(self, **kwargs): - """Initialize the custom component. - - Args: - **kwargs: The kwargs to pass to the component. - """ - component_fn = kwargs.get("component_fn") - - # Set the props. - props_types = typing.get_type_hints(component_fn) if component_fn else {} - props = {key: value for key, value in kwargs.items() if key in props_types} - kwargs = {key: value for key, value in kwargs.items() if key not in props_types} - - event_types = { - key - for key in props - if ( - (get_origin((annotation := props_types.get(key))) or annotation) - == EventHandler - ) - } - - def get_args_spec(key: str) -> types.ArgsSpec | Sequence[types.ArgsSpec]: - type_ = props_types[key] - - return ( - args[0] - if (args := get_args(type_)) - else ( - annotation_args[1] - if get_origin( - annotation := inspect.getfullargspec(component_fn).annotations[ - key - ] - ) - is typing.Annotated - and (annotation_args := get_args(annotation)) - else no_args_event_spec - ) - ) - - super()._post_init( - event_triggers={ - key: EventChain.create( - value=props[key], - args_spec=get_args_spec(key), - key=key, - ) - for key in event_types - }, - **kwargs, - ) - - to_camel_cased_props = { - format.to_camel_case(key): None for key in props if key not in event_types - } - self.get_props = lambda: to_camel_cased_props # pyright: ignore [reportIncompatibleVariableOverride] - - # Unset the style. - self.style = Style() - - # Set the tag to the name of the function. - self.tag = format.to_title_case(self.component_fn.__name__) - - for key, value in props.items(): - # Skip kwargs that are not props. - if key not in props_types: - continue - - camel_cased_key = format.to_camel_case(key) - - # Get the type based on the annotation. - type_ = props_types[key] - - # Handle event chains. - if type_ is EventHandler: - inspect.getfullargspec(component_fn).annotations[key] - self.props[camel_cased_key] = EventChain.create( - value=value, args_spec=get_args_spec(key), key=key - ) - continue - - value = LiteralVar.create(value) - self.props[camel_cased_key] = value - setattr(self, camel_cased_key, value) - - def __eq__(self, other: Any) -> bool: - """Check if the component is equal to another. - - Args: - other: The other component. - - Returns: - Whether the component is equal to the other. - """ - return isinstance(other, CustomComponent) and self.tag == other.tag - - def __hash__(self) -> int: - """Get the hash of the component. - - Returns: - The hash of the component. - """ - return hash(self.tag) - - @classmethod - def get_props(cls) -> Iterable[str]: - """Get the props for the component. - - Returns: - The set of component props. - """ - return () - - @staticmethod - def _get_event_spec_from_args_spec(name: str, event: EventChain) -> Callable: - """Get the event spec from the args spec. - - Args: - name: The name of the event - event: The args spec. - - Returns: - The event spec. - """ - - def fn(*args): - return run_script(Var(name).to(FunctionVar).call(*args)) - - if event.args_spec: - arg_spec = ( - event.args_spec - if not isinstance(event.args_spec, Sequence) - else event.args_spec[0] - ) - names = inspect.getfullargspec(arg_spec).args - fn.__signature__ = inspect.Signature( # pyright: ignore[reportFunctionMemberAccess] - parameters=[ - inspect.Parameter( - name=name, - kind=inspect.Parameter.POSITIONAL_ONLY, - annotation=arg._var_type, - ) - for name, arg in zip( - names, parse_args_spec(event.args_spec)[0], strict=True - ) - ] - ) - - return fn - - def get_prop_vars(self) -> list[Var | Callable]: - """Get the prop vars. - - Returns: - The prop vars. - """ - return [ - Var( - _js_expr=name + CAMEL_CASE_MEMO_MARKER, - _var_type=(prop._var_type if isinstance(prop, Var) else type(prop)), - ).guess_type() - if isinstance(prop, Var) or not isinstance(prop, EventChain) - else CustomComponent._get_event_spec_from_args_spec( - name + CAMEL_CASE_MEMO_MARKER, prop - ) - for name, prop in self.props.items() - ] - - @functools.cache # noqa: B019 - def get_component(self) -> Component: - """Render the component. - - Returns: - The code to render the component. - """ - component = self.component_fn(*self.get_prop_vars()) - - try: - from reflex.utils.prerequisites import get_and_validate_app - - style = get_and_validate_app().app.style - except Exception: - style = {} - - component._add_style_recursive(style) - return component - - def _get_all_app_wrap_components( - self, *, ignore_ids: set[int] | None = None - ) -> dict[tuple[int, str], Component]: - """Get the app wrap components for the custom component. - - Args: - ignore_ids: A set of IDs to ignore to avoid infinite recursion. - - Returns: - The app wrap components. - """ - ignore_ids = ignore_ids or set() - component = self.get_component() - if id(component) in ignore_ids: - return {} - ignore_ids.add(id(component)) - return self.get_component()._get_all_app_wrap_components(ignore_ids=ignore_ids) - - -CUSTOM_COMPONENTS: dict[str, CustomComponent] = {} - - -def _register_custom_component( - component_fn: Callable[..., Component], -): - """Register a custom component to be compiled. - - Args: - component_fn: The function that creates the component. - - Returns: - The custom component. - - Raises: - TypeError: If the tag name cannot be determined. - """ - dummy_props = { - prop: ( - Var( - "", - _var_type=unwrap_var_annotation(annotation), - ).guess_type() - if not types.safe_issubclass(annotation, EventHandler) - else EventSpec(handler=EventHandler(fn=no_args_event_spec)) - ) - for prop, annotation in typing.get_type_hints(component_fn).items() - if prop != "return" - } - dummy_component = CustomComponent._create( - children=[], - component_fn=component_fn, - **dummy_props, - ) - if dummy_component.tag is None: - msg = f"Could not determine the tag name for {component_fn!r}" - raise TypeError(msg) - CUSTOM_COMPONENTS[dummy_component.tag] = dummy_component - return dummy_component - - -def custom_component( - component_fn: Callable[..., Component], -) -> Callable[..., CustomComponent]: - """Create a custom component from a function. - - Args: - component_fn: The function that creates the component. - - Returns: - The decorated function. - """ - - @wraps(component_fn) - def wrapper(*children, **props) -> CustomComponent: - # Remove the children from the props. - props.pop("children", None) - return CustomComponent._create( - children=list(children), component_fn=component_fn, **props - ) - - # Register this component so it can be compiled. - dummy_component = _register_custom_component(component_fn) - if tag := dummy_component.tag: - object.__setattr__( - wrapper, - "_as_var", - lambda: Var( - tag, - _var_type=type[Component], - _var_data=VarData( - imports={ - f"$/{constants.Dirs.UTILS}/components": [ImportVar(tag=tag)], - "@emotion/react": [ - ImportVar(tag="jsx"), - ], - } - ), - ), - ) - - return wrapper - - -# Alias memo to custom_component. -memo = custom_component - - class NoSSRComponent(Component): """A dynamic component that is not rendered on the server.""" diff --git a/reflex/experimental/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py similarity index 98% rename from reflex/experimental/memo.py rename to packages/reflex-base/src/reflex_base/components/memo.py index 776e056cc50..71029f60f45 100644 --- a/reflex/experimental/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -24,7 +24,7 @@ from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER from reflex_base.utils import format from reflex_base.utils.imports import ImportVar -from reflex_base.utils.types import safe_issubclass +from reflex_base.utils.types import safe_issubclass, typehint_issubclass from reflex_base.vars import VarData from reflex_base.vars.base import LiteralVar, Var from reflex_base.vars.function import ( @@ -35,10 +35,6 @@ ReflexCallable, ) from reflex_base.vars.object import RestProp -from reflex_components_core.base.bare import Bare -from reflex_components_core.base.fragment import Fragment - -from reflex.utils import types as type_utils @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) @@ -266,7 +262,7 @@ def _annotation_inner_type(annotation: Any) -> Any: return dict[str, Any] origin = get_origin(annotation) or annotation - if type_utils.safe_issubclass(origin, Var) and (args := get_args(annotation)): + if safe_issubclass(origin, Var) and (args := get_args(annotation)): return args[0] return Any @@ -326,7 +322,7 @@ def _children_annotation_is_valid(annotation: Any) -> bool: Returns: Whether the annotation is valid for children. """ - return _is_var_annotation(annotation) and type_utils.typehint_issubclass( + return _is_var_annotation(annotation) and typehint_issubclass( _annotation_inner_type(annotation), Component ) @@ -504,9 +500,9 @@ def _normalize_component_return(value: Any) -> Component | None: if isinstance(value, Component): return value - if isinstance(value, Var) and type_utils.typehint_issubclass( - value._var_type, Component - ): + if isinstance(value, Var) and typehint_issubclass(value._var_type, Component): + from reflex_components_core.base.bare import Bare + return Bare.create(value) return None @@ -521,6 +517,8 @@ def _lift_rest_props(component: Component) -> Component: Returns: The rewritten component tree. """ + from reflex_components_core.base.bare import Bare + special_props = list(component.special_props) rewritten_children = [] @@ -795,6 +793,8 @@ def _bind_function_runtime_args( # Build the props object passed to the imported FunctionVar. children_value: Any | None = None if children_param is not None: + from reflex_components_core.base.fragment import Fragment + children_value = args[0] if len(args) == 1 else Fragment.create(*args) # Convert rest-prop keys to camelCase to match component memo behavior. @@ -820,8 +820,7 @@ def _is_component_child(value: Any) -> bool: Whether the value is a component child. """ return isinstance(value, Component) or ( - isinstance(value, Var) - and type_utils.typehint_issubclass(value._var_type, Component) + isinstance(value, Var) and typehint_issubclass(value._var_type, Component) ) @@ -1056,6 +1055,8 @@ def passthrough(children: Var[Component]) -> Component: # ``children`` parameter is present in the signature but unused. if not component.children: return new_component + from reflex_components_core.base.bare import Bare + hole_bare = Bare.create(children) captured_hole_child.append(hole_bare) # Substitute the ``{children}`` hole for the original descendants so diff --git a/packages/reflex-base/src/reflex_base/plugins/compiler.py b/packages/reflex-base/src/reflex_base/plugins/compiler.py index ecb55a03d92..5720d27aab7 100644 --- a/packages/reflex-base/src/reflex_base/plugins/compiler.py +++ b/packages/reflex-base/src/reflex_base/plugins/compiler.py @@ -765,9 +765,8 @@ class CompileContext(BaseContext): # Auto-memoize wrapper tags seen during the tree walk (populated by # ``MemoizeStatefulPlugin``). memoize_wrappers: dict[str, None] = dataclasses.field(default_factory=dict) - # Compiler-generated experimental memo definitions for auto-memoized - # stateful wrappers. Stored as ``Any`` to keep ``reflex_base`` decoupled - # from ``reflex.experimental.memo``. + # Compiler-generated memo definitions for auto-memoized stateful wrappers. + # Stored as ``Any`` to avoid an import cycle with ``reflex_base.components.memo``. auto_memo_components: dict[str, Any] = dataclasses.field(default_factory=dict) def compile( diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py b/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py index 7baa29dd51d..5cd2f216405 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py @@ -2,7 +2,8 @@ from reflex_components_core.el.elements.typography import Div -from reflex.components.component import Component, memo +from reflex.components.component import Component +from reflex.components.memo import memo from reflex.vars.base import Var from reflex_components_internal.utils.twmerge import cn @@ -15,7 +16,7 @@ class ClassNames: @memo def skeleton_component( - class_name: str | Var[str] = "", + class_name: Var[str] = "", ) -> Component: """Skeleton component. diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py b/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py index 351505bffc5..8dfe783b480 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py @@ -4,8 +4,10 @@ from reflex_components_core.el.elements.forms import Button from reflex_components_core.el.elements.typography import Div -from reflex.components.component import Component, memo +from reflex.components.component import Component +from reflex.components.memo import memo from reflex.style import LiteralColorMode, color_mode, set_color_mode +from reflex.vars.base import Var from reflex_components_internal.components.icons.hugeicon import hi from reflex_components_internal.utils.twmerge import cn @@ -47,7 +49,7 @@ def theme_switcher(class_name: str = "") -> Component: @memo -def memoized_theme_switcher(class_name: str = "") -> Component: +def memoized_theme_switcher(class_name: Var[str] = "") -> Component: """Memoized theme switcher component. Returns: diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py b/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py index 419e31e6575..125a40022b7 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py @@ -2,7 +2,8 @@ from reflex_components_core.el.elements.media import svg -from reflex.components.component import Component, memo +from reflex.components.component import Component +from reflex.components.memo import memo from reflex.vars.base import Var from reflex_components_internal.components.icons.hugeicon import hi from reflex_components_internal.utils.twmerge import cn @@ -10,7 +11,7 @@ @memo def spinner_component( - class_name: str | Var[str] = "", + class_name: Var[str] = "", ) -> Component: """Create a spinner SVG icon. @@ -44,7 +45,7 @@ def spinner_component( @memo def select_arrow_icon( - class_name: str | Var[str] = "", + class_name: Var[str] = "", ) -> Component: """A select arrow SVG icon. diff --git a/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py b/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py index f6033c83a3a..71760c383b8 100644 --- a/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py +++ b/packages/reflex-components-markdown/src/reflex_components_markdown/markdown.py @@ -13,7 +13,6 @@ BaseComponent, Component, ComponentNamespace, - CustomComponent, field, ) from reflex_base.components.tags.tag import Tag @@ -405,15 +404,7 @@ def _get_map_fn_custom_code_from_children( if isinstance(component, MarkdownComponentMap): custom_code_list.append(component.get_component_map_custom_code()) - # If the component is a custom component(rx.memo), obtain the underlining - # component and get the custom code from the children. - if isinstance(component, CustomComponent): - custom_code_list.extend( - self._get_map_fn_custom_code_from_children( - component.component_fn(*component.get_prop_vars()) - ) - ) - elif isinstance(component, Component): + if isinstance(component, Component): for child in component.children: custom_code_list.extend( self._get_map_fn_custom_code_from_children(child) diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py index a2557b46da7..2da45e16140 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/code.py @@ -11,7 +11,7 @@ @rx.memo -def _plain_code_block(code: str, language: str): +def _plain_code_block(code: rx.Var[str], language: rx.Var[str]) -> rx.Component: """Shared plain code block implementation. Returns: @@ -83,7 +83,7 @@ def code_block(code: str, language: str): @rx.memo -def code_block_dark(code: str, language: str): +def code_block_dark(code: rx.Var[str], language: rx.Var[str]) -> rx.Component: """Code block dark. Returns: diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.py b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.py index 4a0808f6613..c07c6118c43 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/components/blocks/headings.py @@ -144,7 +144,7 @@ def create( @rx.memo -def h1_comp(text: str) -> rx.Component: +def h1_comp(text: rx.Var[str]) -> rx.Component: """H1 comp. Returns: @@ -158,7 +158,7 @@ def h1_comp(text: str) -> rx.Component: @rx.memo -def h1_comp_xd(text: str) -> rx.Component: +def h1_comp_xd(text: rx.Var[str]) -> rx.Component: """H1 comp xd. Returns: @@ -172,7 +172,7 @@ def h1_comp_xd(text: str) -> rx.Component: @rx.memo -def h2_comp(text: str) -> rx.Component: +def h2_comp(text: rx.Var[str]) -> rx.Component: """H2 comp. Returns: @@ -187,7 +187,7 @@ def h2_comp(text: str) -> rx.Component: @rx.memo -def h2_comp_xd(text: str) -> rx.Component: +def h2_comp_xd(text: rx.Var[str]) -> rx.Component: """H2 comp xd. Returns: @@ -202,7 +202,7 @@ def h2_comp_xd(text: str) -> rx.Component: @rx.memo -def h3_comp(text: str) -> rx.Component: +def h3_comp(text: rx.Var[str]) -> rx.Component: """H3 comp. Returns: @@ -217,7 +217,7 @@ def h3_comp(text: str) -> rx.Component: @rx.memo -def h3_comp_xd(text: str) -> rx.Component: +def h3_comp_xd(text: rx.Var[str]) -> rx.Component: """H3 comp xd. Returns: @@ -232,7 +232,7 @@ def h3_comp_xd(text: str) -> rx.Component: @rx.memo -def h4_comp(text: str) -> rx.Component: +def h4_comp(text: rx.Var[str]) -> rx.Component: """H4 comp. Returns: @@ -247,7 +247,7 @@ def h4_comp(text: str) -> rx.Component: @rx.memo -def h4_comp_xd(text: str) -> rx.Component: +def h4_comp_xd(text: rx.Var[str]) -> rx.Component: """H4 comp xd. Returns: @@ -262,7 +262,7 @@ def h4_comp_xd(text: str) -> rx.Component: @rx.memo -def img_comp_xd(src: str) -> rx.Component: +def img_comp_xd(src: rx.Var[str]) -> rx.Component: """Img comp xd. Returns: diff --git a/packages/reflex-site-shared/src/reflex_site_shared/components/code_card.py b/packages/reflex-site-shared/src/reflex_site_shared/components/code_card.py index f8a23424058..a2e1fa0e413 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/components/code_card.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/components/code_card.py @@ -11,8 +11,8 @@ @rx.memo def install_command( - command: str, - show_dollar_sign: bool = True, + command: rx.Var[str], + show_dollar_sign: rx.Var[bool] = True, ) -> rx.Component: """Install command. diff --git a/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py b/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py index f703dc9320c..40ab5b37643 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py @@ -231,7 +231,9 @@ def footer_legal(class_name: str = "") -> rx.Component: @rx.memo -def footer_index(class_name: str = "", grid_class_name: str = "") -> rx.Component: +def footer_index( + class_name: rx.Var[str] = "", grid_class_name: rx.Var[str] = "" +) -> rx.Component: """Full marketing footer: logo, newsletter, links, and legal. Returns: diff --git a/pyi_hashes.json b/pyi_hashes.json index 7f5883643b7..923e1ad621b 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -40,7 +40,7 @@ "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "e3ec310276f9d091fbb0261e523ca9ed", - "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "da02f81678d920a68101c08fe64483a5", + "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "c647559056a01c78eec616f35e930026", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", "packages/reflex-components-radix/src/reflex_components_radix/__init__.pyi": "19216eb3618f68c8a76e5e43801cf4af", @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "c5288f311fe37b23539518ba2a3d4482", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", - "reflex/__init__.pyi": "3a9bb8544cbc338ffaf0a5927d9156df", + "reflex/__init__.pyi": "6624297c011af5b72ff4860d29df4f10", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "82d8699470071df80886a4a6ba8dccfe" } diff --git a/reflex/__init__.py b/reflex/__init__.py index 3a81b098db6..d64f587525e 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -140,9 +140,9 @@ "reflex_base.components.component": [ "Component", "NoSSRComponent", - "memo", "ComponentNamespace", ], + "reflex_base.components.memo": ["memo"], "reflex_components_core.el.elements.media": ["image"], "reflex_components_lucide": ["icon"], **_COMPONENTS_BASE_MAPPING, diff --git a/reflex/app.py b/reflex/app.py index 65ae0a8235b..cba7f48f713 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -190,9 +190,9 @@ def default_overlay_component() -> Component: Returns: The default overlay component, which is a connection banner/toaster set. """ - from reflex_base.components.component import memo + from reflex_base.components.memo import memo - def default_overlay_components(): + def default_overlay_components() -> Component: return Fragment.create( connection_pulser(), connection_toaster(), @@ -1113,10 +1113,10 @@ def _should_compile(self) -> bool: def _setup_sticky_badge(self): """Add the sticky badge to the app.""" - from reflex_base.components.component import memo + from reflex_base.components.memo import memo @memo - def memoized_badge(): + def memoized_badge() -> Component: sticky_badge = sticky() sticky_badge._add_style_recursive({}) return sticky_badge diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 6c986bd1903..3aafa99c1ca 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -11,13 +11,17 @@ from reflex_base import constants from reflex_base.components.component import ( - CUSTOM_COMPONENTS, BaseComponent, Component, ComponentStyle, - CustomComponent, evaluate_style_namespaces, ) +from reflex_base.components.memo import ( + EXPERIMENTAL_MEMOS, + ExperimentalMemoComponentDefinition, + ExperimentalMemoDefinition, + ExperimentalMemoFunctionDefinition, +) from reflex_base.config import get_config from reflex_base.constants.compiler import PageNames, ResetStylesheet from reflex_base.constants.state import FIELD_MARKER @@ -35,12 +39,6 @@ from reflex.compiler import templates, utils from reflex.compiler.plugins import default_page_plugins -from reflex.experimental.memo import ( - EXPERIMENTAL_MEMOS, - ExperimentalMemoComponentDefinition, - ExperimentalMemoDefinition, - ExperimentalMemoFunctionDefinition, -) from reflex.state import BaseState, code_uses_state_contexts from reflex.utils import console, frontend_skeleton, path_ops, prerequisites from reflex.utils.exec import get_compile_context, is_prod_mode @@ -394,54 +392,30 @@ def _compile_component(component: Component) -> str: def _compile_memo_components( - components: Iterable[CustomComponent], experimental_memos: Iterable[ExperimentalMemoDefinition] = (), ) -> tuple[list[tuple[str, str]], dict[str, list[ImportVar]]]: - """Compile each memo/custom-component as its own module plus an index. + """Compile each memo as its own module plus an empty index. Each memo lands in ``.web//.jsx`` with only the imports - it actually uses. Experimental memo wrappers declare their ``library`` as - that per-memo file path so page-side imports resolve directly to the + it actually uses. Memo wrappers declare their ``library`` as that + per-memo file path so page-side imports resolve directly to the individual module. - The ``$/utils/components`` index only re-exports the legacy - ``@rx.memo`` custom components, which are the ones app-level code - (``root.jsx``) imports by name. Keeping experimental memos out of the - index is what lets root's ``import * as utils_components`` avoid - transitively dragging every page-specific memo into the always-loaded - chunk — the tree-shaking win of per-memo files relies on that. + The ``$/utils/components`` index is emitted empty so callers still find + the file at the expected path. Args: - components: The components to compile. - experimental_memos: The experimental memos to compile. + experimental_memos: The memos to compile. Returns: A list of ``(path, code)`` pairs to write — one per memo plus one index — and the aggregated imports across all memo modules. """ per_memo_files: list[tuple[str, str]] = [] - # Only legacy custom components go through the index: they are the ones - # root.jsx/custom code imports by name from ``$/utils/components``. - # Experimental memos declare their library per-file (see - # ``_get_experimental_memo_component_class``) so pages import them - # directly and the index stays small. - index_entries: list[tuple[str, str]] = [] aggregate_imports: dict[str, list[ImportVar]] = {} base_dir = utils.get_memo_components_dir() - for component in components: - component_render, component_imports = utils.compile_custom_component(component) - name = component_render["name"] - code, file_imports = _compile_single_memo_component( - component_render, component_imports - ) - path = _memo_component_file_path(base_dir, name) - specifier = _memo_component_index_specifier(name) - per_memo_files.append((path, code)) - index_entries.append((name, specifier)) - _extend_imports_in_place(aggregate_imports, file_imports) - for memo in experimental_memos: if isinstance(memo, ExperimentalMemoComponentDefinition): memo_render, memo_imports = utils.compile_experimental_component_memo(memo) @@ -463,7 +437,7 @@ def _compile_memo_components( _extend_imports_in_place(aggregate_imports, file_imports) index_path = utils.get_components_path() - index_code = templates.memo_index_template(index_entries) + index_code = templates.memo_index_template([]) return [(index_path, index_code), *per_memo_files], aggregate_imports @@ -678,20 +652,18 @@ def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]: def compile_memo_components( - components: Iterable[CustomComponent], experimental_memos: Iterable[ExperimentalMemoDefinition] = (), ) -> tuple[list[tuple[str, str]], dict[str, list[ImportVar]]]: - """Compile the custom components into one module per memo plus an index. + """Compile the memos into one module per memo plus an index. Args: - components: The custom components to compile. - experimental_memos: The experimental memos to compile. + experimental_memos: The memos to compile. Returns: A list of ``(path, code)`` pairs (one per memo module and one index) alongside the aggregated imports across all memo modules. """ - return _compile_memo_components(components, experimental_memos) + return _compile_memo_components(experimental_memos) def purge_web_pages_dir(): @@ -944,10 +916,10 @@ def _resolve_app_wrap_components( app_wrappers[200, "StrictMode"] = StrictMode.create() if (toaster := app.toaster) is not None: - from reflex_base.components.component import memo + from reflex_base.components.memo import memo @memo - def memoized_toast_provider(): + def memoized_toast_provider() -> Component: return toaster app_wrappers[44, "ToasterProvider"] = Fragment.create(memoized_toast_provider()) @@ -1124,7 +1096,6 @@ def compile_app( all_imports = utils.merge_imports(all_imports, app_root._get_all_imports()) memo_component_files, memo_components_imports = compile_memo_components( - dict.fromkeys(CUSTOM_COMPONENTS.values()), ( *tuple(EXPERIMENTAL_MEMOS.values()), *tuple(compile_ctx.auto_memo_components.values()), diff --git a/reflex/compiler/plugins/memoize.py b/reflex/compiler/plugins/memoize.py index cdd7c3e7c49..dd4abab27ea 100644 --- a/reflex/compiler/plugins/memoize.py +++ b/reflex/compiler/plugins/memoize.py @@ -23,6 +23,7 @@ from typing import Any from reflex_base.components.component import BaseComponent, Component +from reflex_base.components.memo import create_passthrough_component_memo from reflex_base.components.memoize_helpers import ( MemoizationStrategy, _is_structural_memoization_child, @@ -34,8 +35,6 @@ from reflex_base.plugins import ComponentAndChildren, PageContext from reflex_base.plugins.base import Plugin -from reflex.experimental.memo import create_passthrough_component_memo - def _subtree_has_reactive_data( component: Component, _cache: dict[int, bool] | None = None diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 7f8fe249bf3..a0c7a8e5d57 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -14,8 +14,12 @@ from urllib.parse import urlparse from reflex_base import constants -from reflex_base.components.component import Component, ComponentStyle, CustomComponent -from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER, FIELD_MARKER +from reflex_base.components.component import Component, ComponentStyle +from reflex_base.components.memo import ( + ExperimentalMemoComponentDefinition, + ExperimentalMemoFunctionDefinition, +) +from reflex_base.constants.state import FIELD_MARKER from reflex_base.style import Style from reflex_base.utils import format, imports from reflex_base.utils.imports import ImportVar, ParsedImportDict @@ -28,10 +32,6 @@ from reflex_components_core.el.elements.other import Html from reflex_components_core.el.elements.sectioning import Body -from reflex.experimental.memo import ( - ExperimentalMemoComponentDefinition, - ExperimentalMemoFunctionDefinition, -) from reflex.istate.storage import Cookie, LocalStorage, SessionStorage from reflex.state import BaseState, _resolve_delta from reflex.utils import path_ops @@ -321,53 +321,6 @@ def compile_client_storage( } -def compile_custom_component( - component: CustomComponent, -) -> tuple[dict, ParsedImportDict]: - """Compile a custom component. - - Args: - component: The custom component to compile. - - Returns: - A tuple of the compiled component and the imports required by the component. - """ - # Render the component. - render = component.get_component() - - # Get the imports. - imports: ParsedImportDict = {} - for lib, fields in render._get_all_imports().items(): - if lib != component.library: - imports[lib] = fields - continue - - filtered_fields = [field for field in fields if field.tag != component.tag] - if filtered_fields: - imports[lib] = filtered_fields - - imports.setdefault("@emotion/react", []).append(ImportVar("jsx")) - - # Concatenate the props. - props = list(component.props) - - # Compile the component. - return ( - { - "name": component.tag, - "props": props, - "signature": DestructuredArg( - fields=tuple(f"{prop}:{prop}{CAMEL_CASE_MEMO_MARKER}" for prop in props) - ).to_javascript(), - "render": render.render(), - "hooks": render._get_all_hooks(), - "custom_code": render._get_all_custom_code(), - "dynamic_imports": render._get_all_dynamic_imports(), - }, - imports, - ) - - def _apply_component_style_for_compile(component: Component) -> Component: """Apply the app style to a compiled component tree. diff --git a/reflex/components/memo.py b/reflex/components/memo.py new file mode 100644 index 00000000000..4de9661b2e8 --- /dev/null +++ b/reflex/components/memo.py @@ -0,0 +1,4 @@ +# pyright: reportWildcardImportFromLibrary=false +"""Re-export from reflex_base.""" + +from reflex_base.components.memo import * # pragma: no cover diff --git a/reflex/experimental/__init__.py b/reflex/experimental/__init__.py index 5854243bea7..0b3ca628a8d 100644 --- a/reflex/experimental/__init__.py +++ b/reflex/experimental/__init__.py @@ -1,7 +1,9 @@ """Namespace for experimental features.""" from types import SimpleNamespace +from typing import Any +from reflex_base.components.memo import memo as _memo from reflex_base.utils.console import warn from reflex_components_code.shiki_code_block import code_block as code_block @@ -9,7 +11,6 @@ from . import hooks as hooks from .client_state import ClientStateVar as ClientStateVar -from .memo import memo as memo class ExperimentalNamespace(SimpleNamespace): @@ -42,6 +43,16 @@ def run_in_thread(self): self.register_component_warning("run_in_thread") return run_in_thread + @property + def memo(self) -> Any: + """Deprecated alias for :func:`rx.memo`. + + Returns: + The promoted memo decorator from ``reflex_base.components.memo``. + """ + self.register_component_warning("memo") + return _memo + @staticmethod def register_component_warning(component_name: str): """Add component to emitted warnings and throw a warning if it @@ -60,5 +71,4 @@ def register_component_warning(component_name: str): client_state=ClientStateVar.create, hooks=hooks, code_block=code_block, - memo=memo, ) diff --git a/reflex/testing.py b/reflex/testing.py index 9a3ff023049..783f7cb2771 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar import uvicorn -from reflex_base.components.component import CUSTOM_COMPONENTS, CustomComponent +from reflex_base.components.memo import EXPERIMENTAL_MEMOS from reflex_base.config import get_config from reflex_base.environment import environment from reflex_base.registry import RegistrationContext @@ -41,7 +41,6 @@ import reflex.utils.format import reflex.utils.prerequisites import reflex.utils.processes -from reflex.experimental.memo import EXPERIMENTAL_MEMOS from reflex.istate.shared import SharedState as SharedState # To register it. from reflex.state import reload_state_module from reflex.utils import console, js_runtimes @@ -240,11 +239,9 @@ def _get_source_from_app_source(self, app_source: Any) -> str: def _initialize_app(self): # disable telemetry reporting for tests os.environ["REFLEX_TELEMETRY_ENABLED"] = "false" - # Reset global memo registries so previous AppHarness apps do not + # Reset the global memo registry so previous AppHarness apps do not # leak compiled component definitions into the next test app. - CUSTOM_COMPONENTS.clear() EXPERIMENTAL_MEMOS.clear() - CustomComponent.create().get_component.cache_clear() self.app_path.mkdir(parents=True, exist_ok=True) if self.app_source is not None: app_globals = self._get_globals_from_signature(self.app_source) diff --git a/tests/integration/test_memo.py b/tests/integration/test_memo.py deleted file mode 100644 index 07442fab6ce..00000000000 --- a/tests/integration/test_memo.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Integration tests for rx.memo components.""" - -from collections.abc import Generator - -import pytest -from selenium.webdriver.common.by import By - -from reflex.testing import AppHarness - - -def MemoApp(): - """Reflex app with memo components.""" - import reflex as rx - - class FooComponent(rx.Fragment): - def add_custom_code(self) -> list[str]: - return [ - "const foo = 'bar'", - ] - - @rx.memo - def foo_component(t: str): - return FooComponent.create(t, rx.Var("foo")) - - @rx.memo - def foo_component2(t: str): - return FooComponent.create(t, rx.Var("foo")) - - class MemoState(rx.State): - last_value: str = "" - - @rx.event - def set_last_value(self, value: str): - self.last_value = value - - @rx.memo - def my_memoed_component( - some_value: str, - event: rx.EventHandler[rx.event.passthrough_event_spec(str)], - ) -> rx.Component: - return rx.vstack( - rx.button(some_value, id="memo-button", on_click=event(some_value)), - rx.input(id="memo-input", on_change=event), - ) - - def index() -> rx.Component: - return rx.vstack( - rx.vstack( - foo_component(t="foo"), foo_component2(t="bar"), id="memo-custom-code" - ), - rx.text(MemoState.last_value, id="memo-last-value"), - my_memoed_component( - some_value="memod_some_value", event=MemoState.set_last_value - ), - ) - - app = rx.App() - app.add_page(index) - - -@pytest.fixture -def memo_app(tmp_path) -> Generator[AppHarness, None, None]: - """Start MemoApp app at tmp_path via AppHarness. - - Args: - tmp_path: pytest tmp_path fixture - - Yields: - running AppHarness instance - """ - with AppHarness.create( - root=tmp_path, - app_source=MemoApp, - ) as harness: - yield harness - - -def test_memo_app(memo_app: AppHarness): - """Render various memo'd components and assert on the output. - - Args: - memo_app: harness for MemoApp app - """ - assert memo_app.app_instance is not None, "app is not running" - driver = memo_app.frontend() - - # check that the output matches - memo_custom_code_stack = AppHarness.poll_for_or_raise_timeout( - lambda: driver.find_element(By.ID, "memo-custom-code") - ) - assert ( - memo_app.poll_for_content(memo_custom_code_stack, exp_not_equal="") - == "foobarbarbar" - ) - assert memo_custom_code_stack.text == "foobarbarbar" - - # click the button to trigger partial event application - button = driver.find_element(By.ID, "memo-button") - button.click() - last_value = driver.find_element(By.ID, "memo-last-value") - assert memo_app.poll_for_content(last_value, exp_not_equal="") == "memod_some_value" - - # enter text to trigger passed argument to event handler - textbox = driver.find_element(By.ID, "memo-input") - textbox.send_keys("new_value") - AppHarness.expect(lambda: memo_app.poll_for_content(last_value) == "new_value") diff --git a/tests/integration/test_var_operations.py b/tests/integration/test_var_operations.py index 409a0838b2e..383bafa1eb7 100644 --- a/tests/integration/test_var_operations.py +++ b/tests/integration/test_var_operations.py @@ -59,12 +59,16 @@ class VarOperationState(rx.State): app = rx.App() @rx.memo - def memo_comp(list1: list[int], int_var1: int, id: str): + def memo_comp( + list1: rx.Var[list[int]], int_var1: rx.Var[int], id: rx.Var[str] + ) -> rx.Component: return rx.text(list1, int_var1, id=id) @rx.memo - def memo_comp_nested(int_var2: int, id: str): - return memo_comp(list1=[3, 4], int_var1=int_var2, id=id) + def memo_comp_nested(int_var2: rx.Var[int], id: rx.Var[str]) -> rx.Component: + return memo_comp( + list1=rx.Var.create([3, 4]), int_var1=int_var2, id=id + ) @app.add_page def index(): @@ -634,13 +638,13 @@ def index(): id="foreach_list_arg2", ), memo_comp( - list1=VarOperationState.list1, + list1=VarOperationState.list1.to(list[int]), int_var1=VarOperationState.int_var1, - id="memo_comp", + id=rx.Var.create("memo_comp"), ), memo_comp_nested( int_var2=VarOperationState.int_var2, - id="memo_comp_nested", + id=rx.Var.create("memo_comp_nested"), ), # length rx.box( diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index a23c5c9cc67..d4dc0511f95 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -10,6 +10,11 @@ import pytest from reflex_base.components.component import Component from reflex_base.components.component import field as component_field +from reflex_base.components.memo import ( + ExperimentalMemoComponent, + ExperimentalMemoComponentDefinition, + create_passthrough_component_memo, +) from reflex_base.components.memoize_helpers import ( MemoizationStrategy, get_memoization_strategy, @@ -45,11 +50,6 @@ import reflex.compiler.plugins.memoize as memoize_plugin from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin, _should_memoize -from reflex.experimental.memo import ( - ExperimentalMemoComponent, - ExperimentalMemoComponentDefinition, - create_passthrough_component_memo, -) from reflex.state import BaseState STATE_VAR = LiteralVar.create("value")._replace( @@ -297,7 +297,6 @@ def special_child() -> Component: ctx, page_ctx = _compile_single_page(lambda: rx.box(special_child())) memo_files, _memo_imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) memo_code = "\n".join(code for _, code in memo_files) @@ -705,7 +704,6 @@ def create(cls, *children, **props): "expected an auto-memo wrapper to be generated for the leaf" ) memo_files, _memo_imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) memo_code = "\n".join(code for _, code in memo_files) @@ -949,7 +947,6 @@ def page() -> Component: ) memo_files, _memo_imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) memo_code = "\n".join(code for _, code in memo_files) @@ -1047,7 +1044,6 @@ def page() -> Component: ) memo_files, _memo_imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) match_memo_code = next( @@ -1160,7 +1156,6 @@ def page() -> Component: wrapper_tag = next(iter(ctx.memoize_wrappers)) memo_files, _memo_imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) memo_code = next( @@ -1244,7 +1239,6 @@ def page() -> Component: ) memo_files, _memo_imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) memo_code = next( @@ -1593,7 +1587,6 @@ def _compile_memo_module_text(ctx: CompileContext) -> str: from reflex.compiler.compiler import compile_memo_components memo_files, _imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) return "\n".join(code for _, code in memo_files) @@ -2114,7 +2107,6 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: ) ) memo_files, _imports = compile_memo_components( - components=(), experimental_memos=tuple(ctx.auto_memo_components.values()), ) component_module_names = { diff --git a/tests/units/components/markdown/test_markdown.py b/tests/units/components/markdown/test_markdown.py index 512a26a6255..1a00fe67cb9 100644 --- a/tests/units/components/markdown/test_markdown.py +++ b/tests/units/components/markdown/test_markdown.py @@ -1,5 +1,6 @@ import pytest -from reflex_base.components.component import Component, memo +from reflex_base.components.component import Component +from reflex_base.components.memo import memo from reflex_base.vars.base import Var from reflex_components_code.code import CodeBlock from reflex_components_code.shiki_code_block import ShikiHighLevelCodeBlock @@ -36,7 +37,7 @@ def get_fn_body(cls) -> Var: def syntax_highlighter_memoized_component(codeblock: type[Component]): @memo - def code_block(code: str, language: str): + def code_block(code: Var[str], language: Var[str]) -> Component: return Box.create( codeblock.create( code, diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index 9ca3495a2a2..5f07e82f1e2 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -3,13 +3,7 @@ from typing import Any, ClassVar import pytest -from reflex_base.components.component import ( - CUSTOM_COMPONENTS, - Component, - CustomComponent, - custom_component, - field, -) +from reflex_base.components.component import Component, field from reflex_base.constants import EventTriggers from reflex_base.constants.state import FIELD_MARKER from reflex_base.event import ( @@ -46,7 +40,6 @@ _COMPONENTS_BASE_MAPPING, # pyright: ignore[reportAttributeAccessIssue] _COMPONENTS_CORE_MAPPING, # pyright: ignore[reportAttributeAccessIssue] ) -from reflex.compiler.utils import compile_custom_component from reflex.state import BaseState from reflex.utils import imports @@ -272,20 +265,6 @@ def on_click2(): return EventHandler(fn=on_click2) -@pytest.fixture -def my_component(): - """A test component function. - - Returns: - A test component function. - """ - - def my_component(prop1: Var[str], prop2: Var[int]): - return Box.create(prop1, prop2) - - return my_component - - def test_set_style_attrs(component1): """Test that style attributes are set in the dict. @@ -860,52 +839,6 @@ def get_event_triggers(cls) -> dict[str, Any]: C1.create(on_foo=C1State.mock_handler) -def test_create_custom_component(my_component): - """Test that we can create a custom component. - - Args: - my_component: A test custom component. - """ - component = rx.memo(my_component)(prop1="test", prop2=1) - assert component.tag == "MyComponent" - assert set(component.get_props()) == {"prop1", "prop2"} - assert component.tag in CUSTOM_COMPONENTS - - -def test_custom_component_hash(my_component): - """Test that the hash of a custom component is correct. - - Args: - my_component: A test custom component. - """ - component1 = rx.memo(my_component)(prop1="test", prop2=1) - component2 = rx.memo(my_component)(prop1="test", prop2=2) - assert {component1, component2} == {component1} - - -def test_custom_component_wrapper(): - """Test that the wrapper of a custom component is correct.""" - - @custom_component - def my_component(width: Var[int], color: Var[str]): - return rx.box( - width=width, - color=color, - ) - - from reflex_components_radix.themes.typography.text import Text - - ccomponent = my_component( - rx.text("child"), width=LiteralVar.create(1), color=LiteralVar.create("red") - ) - assert isinstance(ccomponent, CustomComponent) - assert len(ccomponent.children) == 1 - assert isinstance(ccomponent.children[0], Text) - - component = ccomponent.get_component() - assert isinstance(component, Box) - - def test_invalid_event_handler_args(component2, test_state: type[TestState]): """Test that an invalid event handler raises an error. @@ -1758,43 +1691,6 @@ class C2(C1): assert 'renamed_prop3:"prop3_2"' in rendered_c2["props"] -def test_custom_component_get_imports(): - class Inner(Component): - tag = "Inner" - library = "inner" - - @rx.memo - def wrapper(): - return Inner.create() - - @rx.memo - def outer(): - return wrapper() - - custom_comp = wrapper() - - # Inner is not imported directly, but it is imported by the custom component. - assert "inner" not in custom_comp._get_all_imports() - assert "outer" not in custom_comp._get_all_imports() - - # The imports are only resolved during compilation. - custom_comp.get_component() - _, imports_inner = compile_custom_component(custom_comp) - assert "inner" in imports_inner - assert "outer" not in imports_inner - - outer_comp = outer() - - # Nested custom components are only imported during compilation. - assert "inner" not in outer_comp._get_all_imports() - - # The imports are only resolved during compilation. - _, imports_outer = compile_custom_component(outer_comp) - assert "inner" not in imports_outer - assert "$/utils/components" in imports_outer - assert imports_outer["$/utils/components"] == [ImportVar(tag="Wrapper")] - - def test_custom_component_declare_event_handlers_in_fields(): class ReferenceComponent(Component): @classmethod diff --git a/tests/units/conftest.py b/tests/units/conftest.py index 36baee0ec8e..477496dd98a 100644 --- a/tests/units/conftest.py +++ b/tests/units/conftest.py @@ -10,14 +10,13 @@ import pytest import pytest_asyncio -from reflex_base.components.component import CUSTOM_COMPONENTS +from reflex_base.components.memo import EXPERIMENTAL_MEMOS from reflex_base.event import Event, EventSpec from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor, EventProcessor from reflex_base.registry import RegistrationContext from reflex.app import App -from reflex.experimental.memo import EXPERIMENTAL_MEMOS from reflex.istate.manager import StateManager from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory @@ -491,17 +490,14 @@ def clean_registration_context() -> Generator[RegistrationContext, None, None]: @pytest.fixture def preserve_memo_registries(): - """Save and restore global memo registries around a test. + """Save and restore the global memo registry around a test. Yields: None """ - custom_components = dict(CUSTOM_COMPONENTS) experimental_memos = dict(EXPERIMENTAL_MEMOS) try: yield finally: - CUSTOM_COMPONENTS.clear() - CUSTOM_COMPONENTS.update(custom_components) EXPERIMENTAL_MEMOS.clear() EXPERIMENTAL_MEMOS.update(experimental_memos) diff --git a/tests/units/experimental/test_memo.py b/tests/units/experimental/test_memo.py index efb006d545d..8b67c0a5ece 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/experimental/test_memo.py @@ -6,7 +6,13 @@ from typing import Any import pytest -from reflex_base.components.component import CUSTOM_COMPONENTS, Component +from reflex_base.components.component import Component +from reflex_base.components.memo import ( + EXPERIMENTAL_MEMOS, + ExperimentalMemoComponent, + ExperimentalMemoComponentDefinition, + ExperimentalMemoFunctionDefinition, +) from reflex_base.style import Style from reflex_base.utils.imports import ImportVar from reflex_base.vars import VarData @@ -16,12 +22,6 @@ import reflex as rx from reflex.compiler import compiler from reflex.compiler import utils as compiler_utils -from reflex.experimental.memo import ( - EXPERIMENTAL_MEMOS, - ExperimentalMemoComponent, - ExperimentalMemoComponentDefinition, - ExperimentalMemoFunctionDefinition, -) @pytest.fixture(autouse=True) @@ -101,7 +101,7 @@ def my_card( assert isinstance(definition, ExperimentalMemoComponentDefinition) assert any(str(prop) == "rest" for prop in definition.component.special_props) - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const MyCard = memo(({children, title:title" in code assert "...rest" in code @@ -125,7 +125,7 @@ def conditional_slot( "contents": "(showRxMemo ? firstRxMemo : secondRxMemo)" } - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const ConditionalSlot = memo(({show:showRxMemo" in code assert "(showRxMemo ? firstRxMemo : secondRxMemo)" in code @@ -149,7 +149,7 @@ def merge_styles( assert '["color"] : "red"' in str(merged) assert '["className"] : "primary"' in str(merged) - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert ( "export const merge_styles = (({base, ...overrides}) => ({...base, ...overrides}));" @@ -167,7 +167,7 @@ def test_component_returning_memo_with_only_rest(): def hover_trigger(rest: rx.RestProp) -> rx.Component: return rx.text("hover me", rest) - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert "memo(({...rest})" in code assert "({," not in code @@ -180,7 +180,7 @@ def test_var_returning_memo_with_only_rest(): def merge_only(overrides: rx.RestProp) -> rx.Var[Any]: return overrides - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert "(({...overrides}) => overrides)" in code assert "({," not in code @@ -208,7 +208,7 @@ def label_slot( assert '["children"]' in str(rendered) assert '["className"] : "slot"' in str(rendered) - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const label_slot = (({children, label, ...rest}) => label);" in code @@ -363,28 +363,25 @@ def bad_import(value: rx.Var[str]) -> rx.Var[str]: ) -def test_compile_memo_components_includes_experimental_functions_and_components(): - """The shared memo output should include both experimental functions and components.""" +def test_compile_memo_components_includes_functions_and_components(): + """The shared memo output should include both function and component memos.""" @rx.memo - def old_wrapper(title: rx.Var[str]) -> rx.Component: + def text_wrapper(title: rx.Var[str]) -> rx.Component: return rx.text(title) - @rx._x.memo + @rx.memo def format_price(amount: rx.Var[int], currency: rx.Var[str]) -> rx.Var[str]: return currency.to(str) + ": $" + amount.to(str) - @rx._x.memo + @rx.memo def my_card(children: rx.Var[rx.Component], *, title: rx.Var[str]) -> rx.Component: return rx.box(rx.heading(title), children) - files, _ = compiler.compile_memo_components( - dict.fromkeys(CUSTOM_COMPONENTS.values()), - tuple(EXPERIMENTAL_MEMOS.values()), - ) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) - assert "export const OldWrapper = memo(" in code + assert "export const TextWrapper = memo(" in code assert "export const format_price =" in code assert "export const MyCard = memo(" in code @@ -443,7 +440,7 @@ def reject_growing_merge(*imports): ) monkeypatch.setattr(compiler_utils, "merge_imports", reject_growing_merge) - files, aggregate_imports = compiler.compile_memo_components((), memos) + files, aggregate_imports = compiler.compile_memo_components(memos) assert len(files) == len(memos) + 1 assert [import_var.tag for import_var in aggregate_imports["shared-lib"]] == [ @@ -536,7 +533,7 @@ def add_custom_code(self) -> list[str]: def foo_component(label: rx.Var[str]) -> rx.Component: return FooComponent.create(label, rx.Var("foo")) - files, _ = compiler.compile_memo_components((), tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) code = "\n".join(c for _, c in files) assert "const foo = 'bar'" in code diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index e1f6c20576b..1fe436c619b 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -6,13 +6,12 @@ import pytest import reflex_base.config -from reflex_base.components.component import CUSTOM_COMPONENTS +from reflex_base.components.memo import EXPERIMENTAL_MEMOS from reflex_base.constants import IS_WINDOWS import reflex.reflex as reflex_cli import reflex.testing as reflex_testing import reflex.utils.prerequisites -from reflex.experimental.memo import EXPERIMENTAL_MEMOS from reflex.testing import AppHarness @@ -88,7 +87,7 @@ def harness_mocks(monkeypatch): def test_app_harness_initialize_clears_memo_registries( tmp_path, preserve_memo_registries, harness_mocks, monkeypatch ): - """Ensure app initialization clears leaked memo registries. + """Ensure app initialization clears the leaked memo registry. Args: tmp_path: pytest tmp_path fixture @@ -98,7 +97,6 @@ def test_app_harness_initialize_clears_memo_registries( """ monkeypatch.setattr(reflex_cli, "_init", lambda **kwargs: None) - CUSTOM_COMPONENTS["FooComponent"] = mock.sentinel.component EXPERIMENTAL_MEMOS["format_value"] = mock.sentinel.memo harness = AppHarness.create( @@ -109,7 +107,6 @@ def test_app_harness_initialize_clears_memo_registries( harness.app_module_path.parent.mkdir(parents=True, exist_ok=True) harness._initialize_app() - assert "FooComponent" not in CUSTOM_COMPONENTS assert "format_value" not in EXPERIMENTAL_MEMOS harness_mocks.get_and_validate_app.assert_called_once_with(reload=True) From 8cc9b19f155eb169eb4e4bdc0738737deefc6041 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 15 May 2026 20:26:49 +0500 Subject: [PATCH 2/7] feat(memo): support rx.EventHandler params in component memos Component-returning `@rx._x.memo` functions can now declare `rx.EventHandler[...]` (and bare `rx.EventHandler`) parameters, which compile to destructured JSX prop callbacks and are wired through `EventChain` at the call site. Var-returning memos still reject event handlers. Refactors per-parameter behavior into a `_MemoParamSpec` table keyed by a new `MemoParamKind` enum (VALUE / CHILDREN / REST / EVENT_TRIGGER), so each kind owns its classification, validation, placeholder construction, call-site binding, and JSX signature emission. Adds a `_MemoCallBinding` accumulator so `_post_init` no longer special-cases prop vs. rest vs. event routing. --- .../src/reflex_base/components/memo.py | 548 +++++++++++++++--- reflex/compiler/utils.py | 11 +- .../integration/tests_playwright/test_memo.py | 97 ++++ tests/units/experimental/test_memo.py | 347 +++++++++++ tests/units/test_app.py | 30 +- 5 files changed, 927 insertions(+), 106 deletions(-) create mode 100644 tests/integration/tests_playwright/test_memo.py diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 71029f60f45..8538919b3e9 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -4,10 +4,11 @@ import dataclasses import inspect -from collections.abc import Callable +from collections.abc import Callable, Mapping from copy import copy +from enum import Enum from functools import cache, update_wrapper -from typing import Any, get_args, get_origin, get_type_hints +from typing import Annotated, Any, get_args, get_origin, get_type_hints from reflex_base import constants from reflex_base.components.component import Component @@ -22,6 +23,7 @@ SpecialAttributes, ) from reflex_base.constants.state import CAMEL_CASE_MEMO_MARKER +from reflex_base.event import EventChain, EventHandler, no_args_event_spec, run_script from reflex_base.utils import format from reflex_base.utils.imports import ImportVar from reflex_base.utils.types import safe_issubclass, typehint_issubclass @@ -37,18 +39,95 @@ from reflex_base.vars.object import RestProp +class MemoParamKind(str, Enum): + """The role a memo parameter plays in the compiled component. + + Each kind owns its full behavior — annotation classification, call-site + validation, placeholder construction, runtime binding, and JS signature + emission — via the per-kind :class:`_MemoParamSpec` instance in + :data:`_SPECS`. Adding a new kind means one new entry in :data:`_SPECS` + and one extra step in :data:`_CLASSIFICATION_ORDER`; the rest of the + module learns nothing else about the new kind. + """ + + VALUE = "value" + CHILDREN = "children" + REST = "rest" + EVENT_TRIGGER = "event_trigger" + + @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class MemoParam: - """Metadata about a memo parameter.""" + """Metadata about an analyzed memo parameter.""" name: str + kind: MemoParamKind annotation: Any - kind: inspect._ParameterKind + parameter_kind: inspect._ParameterKind + js_prop_name: str + placeholder_name: str + kind_data: Any = None default: Any = inspect.Parameter.empty - js_prop_name: str | None = None - placeholder_name: str = "" - is_children: bool = False - is_rest: bool = False + + @property + def spec(self) -> _MemoParamSpec: + """The per-kind behavior bundle for this parameter.""" + return _SPECS[self.kind] + + def make_placeholder(self) -> Any: + """Build the value passed to the memo function during analysis. + + Returns: + The placeholder value (a ``Var``, ``RestProp``, or plain callable). + """ + return self.spec.make_placeholder(self) + + def bind_call_value(self, binding: _MemoCallBinding) -> None: + """Route a user-provided value to props/event_triggers at instantiation. + + Args: + binding: The call-site routing accumulator. + """ + self.spec.bind_call_value(self, binding) + + def signature_field(self) -> str | None: + """The destructured JSX signature entry, or ``None`` if emitted elsewhere. + + Returns: + The destructured field (e.g. ``"event:eventRxMemo"``), or ``None`` + when this kind is emitted out-of-band by the compiler. + """ + return self.spec.signature_field(self) + + +@dataclasses.dataclass(frozen=True, slots=True) +class _MemoParamSpec: + """The role-owned behavior for one :class:`MemoParamKind`. + + Hooks (in classification + lifecycle order): + ``classify``: ``(annotation, param_name) -> (matches, kind_data)``. + Returns whether the annotation belongs to this kind, plus any + kind-specific payload (the args spec for ``EVENT_TRIGGER``). + ``validate``: ``(inspect.Parameter, fn_name, for_component) -> None``. + Raise ``TypeError`` for misuses (no defaults on EH, ``children`` + naming, rest-on-var-memo, etc.). + ``placeholder_name``: choose the destructured JS identifier (Var/EH + use ``camelCase + RxMemo``; children/rest keep the bare name). + ``make_placeholder``: build the analysis-time value passed to the memo + body function (a ``Var``, a ``RestProp``, or a plain callable). + ``bind_call_value``: at instantiation, pop the user value from kwargs + and route it via ``_MemoCallBinding`` to props or event_triggers. + ``signature_field``: the destructured JSX entry, or ``None`` for kinds + emitted out-of-band (REST -> spread; CHILDREN -> hardcoded prefix). + """ + + kind: MemoParamKind + classify: Callable[[Any, str], tuple[bool, Any]] + validate: Callable[[inspect.Parameter, str, bool], None] + placeholder_name: Callable[[str, str, bool], str] + make_placeholder: Callable[[MemoParam], Any] + bind_call_value: Callable[[MemoParam, _MemoCallBinding], None] + signature_field: Callable[[MemoParam], str | None] @dataclasses.dataclass(frozen=True, slots=True) @@ -109,36 +188,17 @@ def _post_init(self, **kwargs): **kwargs: The kwargs to pass to the component. """ definition = kwargs.pop("memo_definition") + binding = _MemoCallBinding(kwargs) - explicit_props = { - param.name - for param in definition.params - if not param.is_children and not param.is_rest - } - component_fields = self.get_fields() - - declared_props = { - key: kwargs.pop(key) for key in list(kwargs) if key in explicit_props - } - - rest_props = {} - if _get_rest_param(definition.params) is not None: - rest_props = { - key: kwargs.pop(key) - for key in list(kwargs) - if key not in component_fields and not SpecialAttributes.is_special(key) - } + for param in definition.params: + param.bind_call_value(binding) - super()._post_init(**kwargs) + has_rest = _get_rest_param(definition.params) is not None + rest_props = binding.take_rest(self.get_fields()) if has_rest else {} - props: dict[str, Any] = {} - for key, value in {**declared_props, **rest_props}.items(): - camel_cased_key = format.to_camel_case(key) - literal_value = LiteralVar.create(value) - props[camel_cased_key] = literal_value - setattr(self, camel_cased_key, literal_value) + super()._post_init(**binding.build_super_kwargs()) - prop_names = tuple(props) + prop_names = binding.finalize(self, rest_props) object.__setattr__(self, "get_props", lambda: prop_names) @@ -261,12 +321,27 @@ def _annotation_inner_type(annotation: Any) -> Any: if _is_rest_annotation(annotation): return dict[str, Any] + annotation = _strip_annotated(annotation) origin = get_origin(annotation) or annotation if safe_issubclass(origin, Var) and (args := get_args(annotation)): return args[0] return Any +def _strip_annotated(annotation: Any) -> Any: + """Unwrap ``Annotated[X, ...]`` to ``X``; pass other annotations through. + + Args: + annotation: The annotation to unwrap. + + Returns: + The inner annotation, or the original if not ``Annotated``. + """ + if get_origin(annotation) is Annotated: + return get_args(annotation)[0] + return annotation + + def _is_rest_annotation(annotation: Any) -> bool: """Check whether an annotation is a RestProp. @@ -276,6 +351,7 @@ def _is_rest_annotation(annotation: Any) -> bool: Returns: Whether the annotation is a RestProp. """ + annotation = _strip_annotated(annotation) origin = get_origin(annotation) or annotation return isinstance(origin, type) and issubclass(origin, RestProp) @@ -289,10 +365,36 @@ def _is_var_annotation(annotation: Any) -> bool: Returns: Whether the annotation is Var-like. """ + annotation = _strip_annotated(annotation) origin = get_origin(annotation) or annotation return isinstance(origin, type) and issubclass(origin, Var) +def _is_event_handler_annotation(annotation: Any) -> tuple[bool, Any]: + """Detect ``EventHandler`` / ``EventHandler[spec]`` / ``EventHandler[s1, s2]``. + + ``EventHandler.__class_getitem__`` returns ``Annotated[EventHandler, spec]`` for a + single spec and ``Annotated[EventHandler, (s1, s2)]`` (a tuple in the single + metadata slot) for multiple specs. + + Args: + annotation: The annotation to inspect. + + Returns: + ``(is_event_handler, args_spec)`` — ``args_spec`` is ``no_args_event_spec`` for + bare ``EventHandler``, a single spec callable for ``EventHandler[spec]``, or + the tuple of specs for the multi-spec form. + """ + if get_origin(annotation) is Annotated: + inner, *metadata = get_args(annotation) + if isinstance(inner, type) and safe_issubclass(inner, EventHandler): + return True, metadata[0] + return False, None + if isinstance(annotation, type) and safe_issubclass(annotation, EventHandler): + return True, no_args_event_spec + return False, None + + def _is_component_annotation(annotation: Any) -> bool: """Check whether an annotation is component-like. @@ -302,6 +404,7 @@ def _is_component_annotation(annotation: Any) -> bool: Returns: Whether the annotation resolves to Component. """ + annotation = _strip_annotated(annotation) origin = get_origin(annotation) or annotation return isinstance(origin, type) and ( safe_issubclass(origin, Component) @@ -328,11 +431,11 @@ def _children_annotation_is_valid(annotation: Any) -> bool: def _get_children_param(params: tuple[MemoParam, ...]) -> MemoParam | None: - return next((param for param in params if param.is_children), None) + return next((p for p in params if p.kind is MemoParamKind.CHILDREN), None) def _get_rest_param(params: tuple[MemoParam, ...]) -> MemoParam | None: - return next((param for param in params if param.is_rest), None) + return next((p for p in params if p.kind is MemoParamKind.REST), None) def _imported_function_var(name: str, return_type: Any) -> FunctionVar: @@ -445,20 +548,267 @@ def _var_placeholder(name: str, annotation: Any) -> Var: return Var(_js_expr=name, _var_type=_annotation_inner_type(annotation)).guess_type() -def _placeholder_for_param(param: MemoParam) -> Var: - """Create a placeholder var for a parameter. +def _event_handler_placeholder(placeholder_name: str, args_spec: Any) -> Callable: + """Placeholder callable that compiles calls to the destructured JS prop. + + Returned as a plain callable (not an ``EventHandler``) so it flows through + ``EventChain.create`` -> ``call_event_fn``, which actually invokes it. + Wrapping in an ``EventHandler`` would skip the function body and bake the + Python function name into the rendered ``ReflexEvent(...)`` payload. Args: - param: The parameter metadata. + placeholder_name: The destructured JS prop identifier (e.g. ``eventRxMemo``). + args_spec: The user-declared spec, or a tuple of specs from + ``EventHandler[s1, s2]``. Only the first spec shapes the placeholder's + signature; the inner-trigger boundary handles the rest. Returns: - The placeholder var. + A plain callable suitable as a memo-function placeholder. """ - if param.is_rest: - return _rest_placeholder(param.placeholder_name) + prop_callback = Var(_js_expr=placeholder_name).to(FunctionVar) + primary_spec = args_spec[0] if isinstance(args_spec, tuple) else args_spec + + def _placeholder(*args: Any) -> Any: + return run_script(prop_callback.call(*args)) + + _placeholder.__signature__ = inspect.signature(primary_spec) # pyright: ignore[reportFunctionMemberAccess] + return _placeholder + + +def _classify_value(annotation: Any, name: str) -> tuple[bool, Any]: + # ``RestProp`` is a ``Var`` subclass, so guard against it here even though + # ``_CLASSIFICATION_ORDER`` already tries REST first — keeping the classifier + # self-exclusive removes the implicit ordering dependency. + return ( + _is_var_annotation(annotation) and not _is_rest_annotation(annotation), + None, + ) + + +def _classify_children(annotation: Any, name: str) -> tuple[bool, Any]: + return ( + name == "children" and _children_annotation_is_valid(annotation), + None, + ) + + +def _classify_rest(annotation: Any, name: str) -> tuple[bool, Any]: + return _is_rest_annotation(annotation), None + + +def _classify_event_trigger(annotation: Any, name: str) -> tuple[bool, Any]: + return _is_event_handler_annotation(annotation) + + +def _validate_noop( + parameter: inspect.Parameter, fn_name: str, for_component: bool +) -> None: + pass + + +def _validate_children( + parameter: inspect.Parameter, fn_name: str, for_component: bool +) -> None: + if parameter.name != "children": + msg = ( + f"`rx.Var[rx.Component]` parameters in `{fn_name}` must be named " + "`children`." + ) + raise TypeError(msg) + + +def _validate_rest( + parameter: inspect.Parameter, fn_name: str, for_component: bool +) -> None: + if parameter.name == "children": + msg = f"`children` in `{fn_name}` cannot be `rx.RestProp`." + raise TypeError(msg) + + +def _validate_event_trigger( + parameter: inspect.Parameter, fn_name: str, for_component: bool +) -> None: + if not for_component: + msg = ( + f"`rx.EventHandler` parameters are only supported on component-" + f"returning memos. Got `{parameter.name}` in `{fn_name}`." + ) + raise TypeError(msg) + if parameter.name == "children": + msg = ( + f"`children` in `{fn_name}` cannot be an `rx.EventHandler`; " + "use `rx.Var[rx.Component]`." + ) + raise TypeError(msg) + if parameter.default is not inspect.Parameter.empty: + msg = ( + f"`rx.EventHandler` parameter `{parameter.name}` in `{fn_name}` " + "must not have a default value." + ) + raise TypeError(msg) + + +def _placeholder_name_value(name: str, js_prop_name: str, for_component: bool) -> str: + return js_prop_name + CAMEL_CASE_MEMO_MARKER if for_component else name + + +def _placeholder_name_passthrough( + name: str, js_prop_name: str, for_component: bool +) -> str: + return name + + +def _make_value_placeholder(param: MemoParam) -> Var: return _var_placeholder(param.placeholder_name, param.annotation) +def _make_rest_placeholder_spec(param: MemoParam) -> RestProp: + return _rest_placeholder(param.placeholder_name) + + +def _make_event_trigger_placeholder(param: MemoParam) -> Callable[..., Any]: + return _event_handler_placeholder(param.placeholder_name, param.kind_data) + + +def _bind_value(param: MemoParam, binding: _MemoCallBinding) -> None: + if param.name in binding.raw_kwargs: + binding.add_prop(param.js_prop_name, binding.take(param.name)) + + +def _bind_children(param: MemoParam, binding: _MemoCallBinding) -> None: + pass + + +def _bind_rest(param: MemoParam, binding: _MemoCallBinding) -> None: + pass + + +def _bind_event_trigger(param: MemoParam, binding: _MemoCallBinding) -> None: + if param.name in binding.raw_kwargs: + binding.add_event_trigger( + param.js_prop_name, binding.take(param.name), param.kind_data + ) + + +def _signature_destructured(param: MemoParam) -> str: + return f"{param.js_prop_name}:{param.placeholder_name}" + + +def _signature_none(param: MemoParam) -> None: + return None + + +_SPECS: dict[MemoParamKind, _MemoParamSpec] = { + MemoParamKind.VALUE: _MemoParamSpec( + kind=MemoParamKind.VALUE, + classify=_classify_value, + validate=_validate_noop, + placeholder_name=_placeholder_name_value, + make_placeholder=_make_value_placeholder, + bind_call_value=_bind_value, + signature_field=_signature_destructured, + ), + MemoParamKind.CHILDREN: _MemoParamSpec( + kind=MemoParamKind.CHILDREN, + classify=_classify_children, + validate=_validate_children, + placeholder_name=_placeholder_name_passthrough, + make_placeholder=_make_value_placeholder, + bind_call_value=_bind_children, + signature_field=_signature_none, + ), + MemoParamKind.REST: _MemoParamSpec( + kind=MemoParamKind.REST, + classify=_classify_rest, + validate=_validate_rest, + placeholder_name=_placeholder_name_passthrough, + make_placeholder=_make_rest_placeholder_spec, + bind_call_value=_bind_rest, + signature_field=_signature_none, + ), + MemoParamKind.EVENT_TRIGGER: _MemoParamSpec( + kind=MemoParamKind.EVENT_TRIGGER, + classify=_classify_event_trigger, + validate=_validate_event_trigger, + placeholder_name=_placeholder_name_value, + make_placeholder=_make_event_trigger_placeholder, + bind_call_value=_bind_event_trigger, + signature_field=_signature_destructured, + ), +} + +# Order matters: REST and CHILDREN before VALUE (``Var[Component]`` matches +# VALUE's classifier, so children must be tried first). EVENT_TRIGGER is +# independent (``Annotated[EventHandler, ...]`` is not a Var), but listing it +# before VALUE makes the precedence explicit. VALUE is the open fallback. +_CLASSIFICATION_ORDER: tuple[MemoParamKind, ...] = ( + MemoParamKind.REST, + MemoParamKind.CHILDREN, + MemoParamKind.EVENT_TRIGGER, + MemoParamKind.VALUE, +) + + +class _MemoCallBinding: + """Accumulates routing decisions for one memo component instantiation. + + Role specs call :meth:`take`, :meth:`add_prop`, and :meth:`add_event_trigger` + via ``param.bind_call_value(binding)``. The component then calls + :meth:`build_super_kwargs` (what :meth:`Component._post_init` should see) and + :meth:`finalize` (apply collected props as attributes after super returns). + """ + + __slots__ = ("_event_triggers", "_props", "raw_kwargs") + + def __init__(self, raw_kwargs: dict[str, Any]) -> None: + self.raw_kwargs = raw_kwargs + self._props: dict[str, Any] = {} + self._event_triggers: dict[str, EventChain | Var] = {} + + def take(self, key: str) -> Any: + return self.raw_kwargs.pop(key) + + def add_prop(self, js_prop_name: str, value: Any) -> None: + self._props[js_prop_name] = LiteralVar.create(value) + + def add_event_trigger(self, js_prop_name: str, value: Any, args_spec: Any) -> None: + self._event_triggers[js_prop_name] = EventChain.create( + value=value, args_spec=args_spec, key=js_prop_name + ) + + def take_rest(self, component_fields: Mapping[str, Any]) -> dict[str, Any]: + rest: dict[str, Any] = {} + for key in list(self.raw_kwargs): + if key in component_fields or SpecialAttributes.is_special(key): + continue + rest[format.to_camel_case(key)] = LiteralVar.create( + self.raw_kwargs.pop(key) + ) + return rest + + def build_super_kwargs(self) -> dict[str, Any]: + """Merge collected event triggers into raw kwargs for ``super()._post_init``. + + Mutates ``raw_kwargs`` in place. Call once per instantiation. + + Returns: + The kwargs to forward to ``Component._post_init``. + """ + if self._event_triggers: + self.raw_kwargs.setdefault("event_triggers", {}).update( + self._event_triggers + ) + return self.raw_kwargs + + def finalize( + self, component: Component, rest_props: dict[str, Any] + ) -> tuple[str, ...]: + all_props = {**self._props, **rest_props} + for key, value in all_props.items(): + setattr(component, key, value) + return tuple(all_props) + + def _evaluate_memo_function( fn: Callable[..., Any], params: tuple[MemoParam, ...], @@ -476,8 +826,8 @@ def _evaluate_memo_function( keyword_args = {} for param in params: - placeholder = _placeholder_for_param(param) - if param.kind in ( + placeholder = param.make_placeholder() + if param.parameter_kind in ( inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD, ): @@ -555,24 +905,13 @@ def _analyze_params( TypeError: If the function signature is not supported. """ signature = inspect.signature(fn) - hints = get_type_hints(fn) + hints = get_type_hints(fn, include_extras=True) params: list[MemoParam] = [] rest_count = 0 for parameter in signature.parameters.values(): - if parameter.kind is inspect.Parameter.VAR_POSITIONAL: - msg = f"`@rx._x.memo` does not support `*args` in `{fn.__name__}`." - raise TypeError(msg) - if parameter.kind is inspect.Parameter.VAR_KEYWORD: - msg = f"`@rx._x.memo` does not support `**kwargs` in `{fn.__name__}`." - raise TypeError(msg) - if parameter.kind is inspect.Parameter.POSITIONAL_ONLY: - msg = ( - f"`@rx._x.memo` does not support positional-only parameters in " - f"`{fn.__name__}`." - ) - raise TypeError(msg) + _check_parameter_kind(parameter, fn.__name__) annotation = hints.get(parameter.name, parameter.annotation) if annotation is inspect.Parameter.empty: @@ -582,26 +921,24 @@ def _analyze_params( ) raise TypeError(msg) - is_rest = _is_rest_annotation(annotation) - is_children = parameter.name == "children" and _children_annotation_is_valid( - annotation - ) - - if parameter.name == "children" and not is_children: + # Children parameters by name must match the children kind exactly — + # otherwise we accept a value-typed `children` and emit confusing JSX. + if ( + parameter.name == "children" + and not _children_annotation_is_valid(annotation) + and not _is_event_handler_annotation(annotation)[0] + ): msg = ( f"`children` in `{fn.__name__}` must be annotated as " "`rx.Var[rx.Component]`." ) raise TypeError(msg) - if not is_rest and not _is_var_annotation(annotation): - msg = ( - f"All parameters of `{fn.__name__}` must be annotated as `rx.Var[...]` " - f"or `rx.RestProp`, got `{annotation}` for `{parameter.name}`." - ) - raise TypeError(msg) + kind, kind_data = _classify_parameter(annotation, parameter.name, fn.__name__) + spec = _SPECS[kind] + spec.validate(parameter, fn.__name__, for_component) - if is_rest: + if kind is MemoParamKind.REST: rest_count += 1 if rest_count > 1: msg = ( @@ -610,28 +947,77 @@ def _analyze_params( raise TypeError(msg) js_prop_name = format.to_camel_case(parameter.name) - placeholder_name = ( - parameter.name - if is_children or is_rest or not for_component - else js_prop_name + CAMEL_CASE_MEMO_MARKER + placeholder_name = spec.placeholder_name( + parameter.name, js_prop_name, for_component ) params.append( MemoParam( name=parameter.name, + kind=kind, + kind_data=kind_data, annotation=annotation, - kind=parameter.kind, + parameter_kind=parameter.kind, default=parameter.default, js_prop_name=js_prop_name, placeholder_name=placeholder_name, - is_children=is_children, - is_rest=is_rest, ) ) return tuple(params) +def _check_parameter_kind(parameter: inspect.Parameter, fn_name: str) -> None: + """Reject Python parameter kinds (``*args`` / ``**kwargs`` / positional-only) + that memo does not support. + + Args: + parameter: The parameter to check. + fn_name: The function name for error messages. + + Raises: + TypeError: If the parameter uses an unsupported kind. + """ + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + msg = f"`@rx._x.memo` does not support `*args` in `{fn_name}`." + raise TypeError(msg) + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + msg = f"`@rx._x.memo` does not support `**kwargs` in `{fn_name}`." + raise TypeError(msg) + if parameter.kind is inspect.Parameter.POSITIONAL_ONLY: + msg = ( + f"`@rx._x.memo` does not support positional-only parameters in `{fn_name}`." + ) + raise TypeError(msg) + + +def _classify_parameter( + annotation: Any, param_name: str, fn_name: str +) -> tuple[MemoParamKind, Any]: + """Walk ``_CLASSIFICATION_ORDER`` and return the first matching kind. + + Args: + annotation: The parameter annotation. + param_name: The parameter name (some kinds care, e.g. ``children``). + fn_name: The function name for error messages. + + Returns: + The matched ``(kind, kind_data)``. + + Raises: + TypeError: If no kind matches. + """ + for kind in _CLASSIFICATION_ORDER: + matched, kind_data = _SPECS[kind].classify(annotation, param_name) + if matched: + return kind, kind_data + msg = ( + f"All parameters of `{fn_name}` must be annotated as `rx.Var[...]` " + f"or `rx.RestProp`, got `{annotation}` for `{param_name}`." + ) + raise TypeError(msg) + + def _create_function_definition( fn: Callable[..., Any], return_annotation: Any, @@ -661,7 +1047,9 @@ def _create_function_definition( args_names=( DestructuredArg( fields=tuple( - param.placeholder_name for param in params if not param.is_rest + param.placeholder_name + for param in params + if param.kind is not MemoParamKind.REST ), rest=( rest_param.placeholder_name if rest_param is not None else None @@ -764,7 +1152,7 @@ def _bind_function_runtime_args( explicit_params = [ param for param in definition.params - if not param.is_rest and not param.is_children + if param.kind not in (MemoParamKind.REST, MemoParamKind.CHILDREN) ] explicit_values = {} remaining_props = kwargs.copy() @@ -901,7 +1289,7 @@ def __init__(self, definition: ExperimentalMemoComponentDefinition): self._explicit_params = [ param for param in definition.params - if not param.is_children and not param.is_rest + if param.kind not in (MemoParamKind.CHILDREN, MemoParamKind.REST) ] update_wrapper(self, definition.fn) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index a0c7a8e5d57..ef3e0c71674 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -18,6 +18,7 @@ from reflex_base.components.memo import ( ExperimentalMemoComponentDefinition, ExperimentalMemoFunctionDefinition, + MemoParamKind, ) from reflex_base.constants.state import FIELD_MARKER from reflex_base.style import Style @@ -432,15 +433,17 @@ def compile_experimental_component_memo( imports.setdefault("@emotion/react", []).append(ImportVar("jsx")) signature_fields = [ - f"{param.js_prop_name}:{param.placeholder_name}" + field for param in definition.params - if not param.is_children and not param.is_rest + if (field := param.signature_field()) is not None ] - if any(param.is_children for param in definition.params): + if any(p.kind is MemoParamKind.CHILDREN for p in definition.params): signature_fields.insert(0, "children") - rest_param = next((param for param in definition.params if param.is_rest), None) + rest_param = next( + (p for p in definition.params if p.kind is MemoParamKind.REST), None + ) return ( { diff --git a/tests/integration/tests_playwright/test_memo.py b/tests/integration/tests_playwright/test_memo.py new file mode 100644 index 00000000000..fcd99170df2 --- /dev/null +++ b/tests/integration/tests_playwright/test_memo.py @@ -0,0 +1,97 @@ +"""Integration tests for ``rx.memo`` runtime behavior. + +Covers behaviors previously exercised by the deleted +``tests/integration/test_memo.py`` (Selenium): partial-application of an +``EventHandler`` prop (``event(some_value)``) and raw pass-through to an +inner event trigger (``on_change=event``). +""" + +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def MemoApp(): + """App exercising ``rx.memo`` with ``EventHandler`` props.""" + import reflex as rx + + class MemoState(rx.State): + last_value: str = "" + + @rx.event + def set_last_value(self, value: str): + self.last_value = value + + @rx.memo + def my_memoed_component( + some_value: rx.Var[str], + event: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + return rx.vstack( + rx.button(some_value, id="memo-button", on_click=event(some_value)), + rx.input(id="memo-input", on_change=event), + ) + + def index() -> rx.Component: + return rx.vstack( + rx.text(MemoState.last_value, id="memo-last-value"), + my_memoed_component( + some_value="memod_some_value", event=MemoState.set_last_value + ), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def memo_app( + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Run the memo app under an AppHarness. + + Args: + tmp_path_factory: Pytest fixture for creating temporary directories. + + Yields: + The running harness. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("memo_app"), + app_source=MemoApp, + ) as harness: + yield harness + + +def test_memo_event_handler_partial_application( + memo_app: AppHarness, page: Page +) -> None: + """Clicking a button whose ``on_click`` is ``event(some_value)`` dispatches it. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + + expect(page.locator("#memo-last-value")).to_have_text("") + page.click("#memo-button") + expect(page.locator("#memo-last-value")).to_have_text("memod_some_value") + + +def test_memo_event_handler_raw_pass_through(memo_app: AppHarness, page: Page) -> None: + """Typing into an input whose ``on_change`` is the raw handler dispatches it. + + Args: + memo_app: Running app harness. + page: Playwright page. + """ + assert memo_app.frontend_url is not None + page.goto(memo_app.frontend_url) + + page.locator("#memo-input").fill("typed_value") + expect(page.locator("#memo-last-value")).to_have_text("typed_value") diff --git a/tests/units/experimental/test_memo.py b/tests/units/experimental/test_memo.py index 8b67c0a5ece..7fcb34594f1 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/experimental/test_memo.py @@ -8,12 +8,18 @@ import pytest from reflex_base.components.component import Component from reflex_base.components.memo import ( + _SPECS, EXPERIMENTAL_MEMOS, ExperimentalMemoComponent, ExperimentalMemoComponentDefinition, ExperimentalMemoFunctionDefinition, + MemoParam, + MemoParamKind, + _MemoCallBinding, ) +from reflex_base.event import EventChain, EventHandler, no_args_event_spec from reflex_base.style import Style +from reflex_base.utils import format as format_utils from reflex_base.utils.imports import ImportVar from reflex_base.vars import VarData from reflex_base.vars.base import Var @@ -537,3 +543,344 @@ def foo_component(label: rx.Var[str]) -> rx.Component: code = "\n".join(c for _, c in files) assert "const foo = 'bar'" in code + + +def test_component_memo_accepts_event_handler(): + """Component memos should accept EventHandler params with passthrough specs.""" + + @rx._x.memo + def eh_memo( + some_value: rx.Var[str], + event: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + return rx.vstack( + rx.button(some_value, on_click=event(some_value)), + rx.input(on_change=event), + ) + + definition = EXPERIMENTAL_MEMOS["EhMemo"] + assert isinstance(definition, ExperimentalMemoComponentDefinition) + event_param = next(p for p in definition.params if p.name == "event") + assert event_param.kind is MemoParamKind.EVENT_TRIGGER + assert event_param.kind_data is not None + assert event_param.kind_data is not no_args_event_spec + + +def test_component_memo_accepts_bare_event_handler(): + """Component memos should accept bare EventHandler (no spec) params.""" + + @rx._x.memo + def bare_eh_memo(event: rx.EventHandler) -> rx.Component: + return rx.button("click", on_click=event()) + + definition = EXPERIMENTAL_MEMOS["BareEhMemo"] + assert isinstance(definition, ExperimentalMemoComponentDefinition) + event_param = next(p for p in definition.params if p.name == "event") + assert event_param.kind is MemoParamKind.EVENT_TRIGGER + assert event_param.kind_data is no_args_event_spec + + +def test_component_memo_event_handler_compiles_to_prop_callback(): + """`event(value)` and `on_change=event` should compile to the destructured JS prop.""" + + @rx._x.memo + def eh_compile_memo( + some_value: rx.Var[str], + event: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + return rx.vstack( + rx.button(some_value, on_click=event(some_value)), + rx.input(on_change=event), + ) + + files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + code = "\n".join(c for _, c in files) + + # Signature destructures the EH prop with the RxMemo suffix. + assert "event:eventRxMemo" in code + # Partial application: event(some_value) -> eventRxMemo(someValueRxMemo). + assert "eventRxMemo(someValueRxMemo)" in code + # Raw pass-through: on_change=event -> eventRxMemo(...input event arg...). + assert ( + "eventRxMemo(_ev_0)" in code or "eventRxMemo(" in code.split("onChange:", 1)[1] + ) + + +def test_component_memo_event_handler_wires_event_chain_at_call_site(): + """Instantiating an EH memo should wrap the handler in an EventChain trigger.""" + + def _handler_fn(value: str): # pyright: ignore[reportUnusedFunction] + pass + + raw_handler = EventHandler(fn=_handler_fn) + + @rx._x.memo + def eh_wired_memo( + some_value: rx.Var[str], + event: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + return rx.button(some_value, on_click=event(some_value)) + + component = eh_wired_memo(some_value="hello", event=raw_handler) + assert isinstance(component, ExperimentalMemoComponent) + # EH props live on event_triggers, not in get_props(). + assert "event" not in component.get_props() + assert "event" in component.event_triggers + assert isinstance(component.event_triggers["event"], EventChain) + + +def test_var_returning_memo_rejects_event_handler(): + """Var-returning memos should reject EventHandler params.""" + with pytest.raises(TypeError, match="component-returning"): + + @rx._x.memo + def bad_var_eh( + event: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Var[str]: + return rx.Var.create("x") + + +def test_component_memo_rejects_event_handler_with_default(): + """EH params should not allow defaults (matches old CustomComponent behavior).""" + with pytest.raises(TypeError, match="default"): + + @rx._x.memo + def bad_eh_default( + event: rx.EventHandler[rx.event.passthrough_event_spec(str)] = None, # pyright: ignore[reportArgumentType] + ) -> rx.Component: + return rx.button("hi") + + +def test_component_memo_rejects_event_handler_named_children(): + """A `children` parameter must not be an EventHandler.""" + with pytest.raises(TypeError, match="children"): + + @rx._x.memo + def bad_eh_children( + children: rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) -> rx.Component: + return rx.box() + + +# --------------------------------------------------------------------------- +# Interface-level tests: target the _MemoParamSpec Seam directly. +# These exercise per-kind behavior without going through the @rx.memo decorator, +# giving a tight feedback loop for adding new kinds in the future. +# --------------------------------------------------------------------------- + + +def _make_param( + *, + name: str = "x", + kind: MemoParamKind, + annotation: Any = None, + kind_data: Any = None, + placeholder_name: str | None = None, + js_prop_name: str | None = None, +) -> MemoParam: + """Build a MemoParam directly, bypassing _analyze_params. + + Returns: + A populated ``MemoParam`` with sensible defaults for tests. + """ + import inspect as _inspect + + js = js_prop_name if js_prop_name is not None else format_utils.to_camel_case(name) + return MemoParam( + name=name, + kind=kind, + kind_data=kind_data, + annotation=annotation if annotation is not None else rx.Var[int], + parameter_kind=_inspect.Parameter.KEYWORD_ONLY, + js_prop_name=js, + placeholder_name=placeholder_name if placeholder_name is not None else name, + ) + + +def test_classify_routes_each_annotation_to_the_expected_kind(): + """Ordered classification routes each supported annotation to one kind.""" + from reflex_base.components.memo import _classify_parameter + + cases = [ + ("var", rx.Var[int], "x", MemoParamKind.VALUE), + ("rest", rx.RestProp, "rest", MemoParamKind.REST), + ( + "event_with_spec", + rx.EventHandler[rx.event.passthrough_event_spec(str)], + "event", + MemoParamKind.EVENT_TRIGGER, + ), + ("bare_event", rx.EventHandler, "event", MemoParamKind.EVENT_TRIGGER), + ("children_var", rx.Var[rx.Component], "children", MemoParamKind.CHILDREN), + # Var[Component] *not* named children classifies as VALUE — that's the + # path conditional_slot/component-typed slots take in the existing suite. + ("named_x_var_component", rx.Var[rx.Component], "x", MemoParamKind.VALUE), + ] + for case_name, annotation, param_name, expected in cases: + kind, _ = _classify_parameter(annotation, param_name, "test_fn") + assert kind is expected, f"{case_name}: got {kind}, expected {expected}" + + +def test_classify_value_excludes_rest_independent_of_order(): + """The VALUE classifier must reject RestProp even called in isolation. + + ``_CLASSIFICATION_ORDER`` puts REST before VALUE, but the classifier itself + is also self-exclusive so a reordering wouldn't silently regress. + """ + assert _SPECS[MemoParamKind.VALUE].classify(rx.RestProp, "x") == (False, None) + assert _SPECS[MemoParamKind.VALUE].classify(rx.Var[int], "x") == (True, None) + + +def test_children_classifier_requires_named_children(): + """CHILDREN is the only name-sensitive kind; verify it gates on the name.""" + spec = _SPECS[MemoParamKind.CHILDREN] + component_var_annotation = rx.Var[rx.Component] + assert spec.classify(component_var_annotation, "children")[0] is True + assert spec.classify(component_var_annotation, "x")[0] is False + + +def test_value_make_placeholder_returns_typed_var(): + """VALUE kind builds a Var placeholder whose _var_type unwraps the annotation.""" + param = _make_param( + kind=MemoParamKind.VALUE, + annotation=rx.Var[int], + placeholder_name="xRxMemo", + ) + placeholder = param.make_placeholder() + assert isinstance(placeholder, Var) + assert placeholder._js_expr == "xRxMemo" + + +def test_event_trigger_make_placeholder_returns_plain_callable(): + """EVENT_TRIGGER kind builds a plain callable, not an EventHandler. + + The body's `event(value)` call site must compile to the destructured JS + prop name, which requires call_event_fn to actually execute the placeholder. + A synthetic EventHandler(fn=_stub) would bake the Python identifier into + the rendered ReflexEvent instead. + """ + spec = rx.event.passthrough_event_spec(str) + param = _make_param( + name="event", + kind=MemoParamKind.EVENT_TRIGGER, + annotation=rx.EventHandler[spec], + kind_data=spec, + placeholder_name="eventRxMemo", + js_prop_name="event", + ) + placeholder = param.make_placeholder() + assert callable(placeholder) + assert not isinstance(placeholder, EventHandler) + + arg = Var(_js_expr="someValueRxMemo", _var_type=str) + rendered = str(placeholder(arg)) + assert "eventRxMemo" in rendered + assert "someValueRxMemo" in rendered + + +def test_bind_value_routes_to_props(): + """VALUE binding pops the kwarg into binding._props (camelCased).""" + binding = _MemoCallBinding({"my_value": 42, "other": "x"}) + param = _make_param(name="my_value", kind=MemoParamKind.VALUE) + param.bind_call_value(binding) + + assert "my_value" not in binding.raw_kwargs + assert "other" in binding.raw_kwargs # untouched + assert binding._props["myValue"]._js_expr == "42" + assert binding._event_triggers == {} + + +def test_bind_event_trigger_routes_to_event_triggers(): + """EVENT_TRIGGER binding wraps the value in an EventChain on event_triggers.""" + + def _handler(value: str): + pass + + handler = EventHandler(fn=_handler) + spec = rx.event.passthrough_event_spec(str) + binding = _MemoCallBinding({"event": handler}) + param = _make_param( + name="event", + kind=MemoParamKind.EVENT_TRIGGER, + kind_data=spec, + ) + + param.bind_call_value(binding) + assert "event" not in binding.raw_kwargs + assert binding._props == {} + assert isinstance(binding._event_triggers["event"], EventChain) + + +def test_bind_children_and_rest_are_noops_at_the_param_level(): + """CHILDREN comes in positionally; REST is swept by binding.take_rest.""" + binding = _MemoCallBinding({"children": object(), "extra": 1}) + children_param = _make_param(name="children", kind=MemoParamKind.CHILDREN) + rest_param = _make_param(name="rest", kind=MemoParamKind.REST) + + children_param.bind_call_value(binding) + rest_param.bind_call_value(binding) + + # Neither method consumed any kwarg. + assert binding.raw_kwargs == { + "children": binding.raw_kwargs["children"], + "extra": 1, + } + assert binding._props == {} + assert binding._event_triggers == {} + + +def test_take_rest_sweeps_unconsumed_keys_into_camel_cased_dict(): + """binding.take_rest collects every leftover kwarg not on the Component.""" + binding = _MemoCallBinding({"foo_bar": "x", "class_name": "y"}) + rest = binding.take_rest(component_fields={}) + assert set(rest) == {"fooBar", "className"} + assert binding.raw_kwargs == {} + + +@pytest.mark.parametrize( + ("kind", "expected"), + [ + (MemoParamKind.VALUE, "amount:amountRxMemo"), + (MemoParamKind.EVENT_TRIGGER, "amount:amountRxMemo"), + (MemoParamKind.CHILDREN, None), + (MemoParamKind.REST, None), + ], +) +def test_signature_field_for_each_kind(kind: MemoParamKind, expected: str | None): + """VALUE/EVENT_TRIGGER destructure; CHILDREN/REST emit out-of-band.""" + param = _make_param( + name="amount", + kind=kind, + js_prop_name="amount", + placeholder_name="amountRxMemo", + ) + assert param.signature_field() == expected + + +def test_event_trigger_validate_rejects_default_directly(): + """The validate hook on _SPECS[EVENT_TRIGGER] rejects defaults without + going through the decorator. This pins per-kind invariants at the Seam. + """ + import inspect as _inspect + + parameter = _inspect.Parameter( + name="event", + kind=_inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=rx.EventHandler[rx.event.passthrough_event_spec(str)], + ) + with pytest.raises(TypeError, match="default"): + _SPECS[MemoParamKind.EVENT_TRIGGER].validate(parameter, "fn", True) + + +def test_event_trigger_validate_rejects_in_var_returning_memo(): + """EVENT_TRIGGER is only valid on component-returning memos.""" + import inspect as _inspect + + parameter = _inspect.Parameter( + name="event", + kind=_inspect.Parameter.KEYWORD_ONLY, + annotation=rx.EventHandler, + ) + with pytest.raises(TypeError, match="component-returning"): + _SPECS[MemoParamKind.EVENT_TRIGGER].validate(parameter, "fn", False) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index bcd90c9db1e..c3733a5828c 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -37,20 +37,16 @@ import reflex as rx from reflex import AdminDash, constants -from reflex.app import App, ComponentCallable, upload +from reflex._upload import upload +from reflex.app import App, ComponentCallable from reflex.environment import environment +from reflex.istate.data import RouterData from reflex.istate.manager.disk import StateManagerDisk from reflex.istate.manager.memory import StateManagerMemory from reflex.istate.manager.redis import StateManagerRedis from reflex.istate.manager.token import BaseStateToken from reflex.model import Model -from reflex.state import ( - BaseState, - OnLoadInternalState, - RouterData, - State, - reload_state_module, -) +from reflex.state import BaseState, OnLoadInternalState, State, reload_state_module from .conftest import chdir from .states import GenState @@ -2234,7 +2230,7 @@ def test_compile_writes_app_wrap_memo_components( compilable_app: tuple[App, Path], mocker, ) -> None: - """App-wrap memo components are emitted to the shared components module.""" + """App-wrap memo components are emitted as per-memo modules.""" conf = rx.Config(app_name="testing") mocker.patch("reflex_base.config._get_config", return_value=conf) app, web_dir = compilable_app @@ -2242,19 +2238,9 @@ def test_compile_writes_app_wrap_memo_components( app.add_page(rx.box("Index"), route="/") app._compile() - components_index = ( - web_dir - / constants.Dirs.UTILS - / f"{constants.PageNames.COMPONENTS}{constants.Ext.JSX}" - ).read_text() - - # Per-memo modules live under .web/utils/components/; the index re-exports - # each one so page-side ``$/utils/components`` resolves the same tags. - assert "DefaultOverlayComponents" in components_index - assert "MemoizedToastProvider" in components_index - assert 'from "./components/DefaultOverlayComponents"' in components_index - assert 'from "./components/MemoizedToastProvider"' in components_index - + # Per-memo modules live under .web/utils/components/; each memo wrapper + # declares its ``library`` as the per-memo file path, so pages import it + # directly and the top-level index is intentionally empty. memo_dir = web_dir / constants.Dirs.UTILS / constants.PageNames.COMPONENTS assert (memo_dir / f"DefaultOverlayComponents{constants.Ext.JSX}").exists() assert (memo_dir / f"MemoizedToastProvider{constants.Ext.JSX}").exists() From 3bf899bf7dc5a8b0ed8c09ae9290d198872a8c98 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 15 May 2026 23:37:20 +0500 Subject: [PATCH 3/7] chore(memo): annotate rx.memo return types and tighten arrow_svg sig Add explicit rx.Component return annotations to memoized helpers across docs and internal packages, and narrow arrow_svg_component's class_name to Var[str] now that rx.memo handles the conversion. --- docs/enterprise/drag-and-drop.md | 2 +- docs/enterprise/react_flow/nodes.md | 4 +++- docs/library/data-display/icon.md | 2 +- .../blocks/lemcal.py | 20 +++++++++---------- .../components/icons/others.py | 8 +++++--- .../src/reflex_site_shared/views/cta_card.py | 2 +- 6 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/enterprise/drag-and-drop.md b/docs/enterprise/drag-and-drop.md index b793ff7095e..15934f3eb6a 100644 --- a/docs/enterprise/drag-and-drop.md +++ b/docs/enterprise/drag-and-drop.md @@ -34,7 +34,7 @@ class BasicDndState(rx.State): @rx.memo -def draggable_card(): +def draggable_card() -> rx.Component: return rxe.dnd.draggable( rx.card( rx.text("Drag me!", weight="bold"), diff --git a/docs/enterprise/react_flow/nodes.md b/docs/enterprise/react_flow/nodes.md index 7bb14a1b672..c9a97a96289 100644 --- a/docs/enterprise/react_flow/nodes.md +++ b/docs/enterprise/react_flow/nodes.md @@ -201,7 +201,9 @@ class CustomNodeState(rx.State): @rx.memo -def color_selector_node(data: rx.Var[dict], isConnectable: rx.Var[bool]): +def color_selector_node( + data: rx.Var[dict], isConnectable: rx.Var[bool] +) -> rx.Component: data = data.to(dict) return rx.el.div( rxe.flow.handle( diff --git a/docs/library/data-display/icon.md b/docs/library/data-display/icon.md index 9c8dd449a44..36a9bf78df7 100644 --- a/docs/library/data-display/icon.md +++ b/docs/library/data-display/icon.md @@ -13,7 +13,7 @@ icon_search_cs = ClientStateVar.create("icon_search", default="") @rx.memo -def lucide_icons(): +def lucide_icons() -> rx.Component: return rx.box( rx.box( rx.box( diff --git a/packages/reflex-components-internal/src/reflex_components_internal/blocks/lemcal.py b/packages/reflex-components-internal/src/reflex_components_internal/blocks/lemcal.py index 35a108fb911..75195eb04fb 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/blocks/lemcal.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/blocks/lemcal.py @@ -9,8 +9,17 @@ LEMCAL_DEMO_URL = "https://app.lemcal.com/@alek/reflex-demo-call" +def lemcal_script(**props) -> rx.Component: + """Return the Lemcal integrations script tag.""" + return rx.script( + src="https://cdn.lemcal.com/lemcal-integrations.min.js", + defer=True, + **props, + ) + + @rx.memo -def lemcal_booking_calendar(): +def lemcal_booking_calendar() -> rx.Component: """Return the Lemcal booking calendar.""" return rx.fragment( rx.el.div( @@ -31,15 +40,6 @@ def lemcal_booking_calendar(): ) -def lemcal_script(**props) -> rx.Component: - """Return the Lemcal integrations script tag.""" - return rx.script( - src="https://cdn.lemcal.com/lemcal-integrations.min.js", - defer=True, - **props, - ) - - def lemcal_dialog(trigger: rx.Component, **props) -> rx.Component: """Return a Lemcal dialog container element.""" class_name = cn("w-auto", props.pop("class_name", "")) diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py b/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py index 125a40022b7..e513590df74 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py @@ -8,10 +8,12 @@ from reflex_components_internal.components.icons.hugeicon import hi from reflex_components_internal.utils.twmerge import cn +_EMPTY_STR_VAR: Var[str] = Var.create("") + @memo def spinner_component( - class_name: Var[str] = "", + class_name: Var[str] = _EMPTY_STR_VAR, ) -> Component: """Create a spinner SVG icon. @@ -45,7 +47,7 @@ def spinner_component( @memo def select_arrow_icon( - class_name: Var[str] = "", + class_name: Var[str] = _EMPTY_STR_VAR, ) -> Component: """A select arrow SVG icon. @@ -59,7 +61,7 @@ def select_arrow_icon( @memo -def arrow_svg_component(class_name: str | Var[str] = "") -> Component: +def arrow_svg_component(class_name: Var[str] = _EMPTY_STR_VAR) -> Component: """Create a tooltip arrow SVG icon. The arrow SVG icon. diff --git a/packages/reflex-site-shared/src/reflex_site_shared/views/cta_card.py b/packages/reflex-site-shared/src/reflex_site_shared/views/cta_card.py index 16803ae025e..efa1513b009 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/views/cta_card.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/views/cta_card.py @@ -9,7 +9,7 @@ @rx.memo -def cta_card(): +def cta_card() -> rx.Component: """Cta card. Returns: From 6cb4b9b8ac0d622d1a8f8e815a5de6a22c207fb9 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 16 May 2026 00:07:52 +0500 Subject: [PATCH 4/7] chore(memo): re-add experimental memo shim as deprecated alias Restore reflex/experimental/memo.py as a thin module redirect to reflex_base.components.memo so existing rx.experimental.memo imports keep working with a deprecation warning. --- pyi_hashes.json | 4 ++-- reflex/experimental/memo.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 reflex/experimental/memo.py diff --git a/pyi_hashes.json b/pyi_hashes.json index 2a34ba27245..2fb7aa54472 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -39,7 +39,7 @@ "packages/reflex-components-core/src/reflex_components_core/react_router/dom.pyi": "1074a512195ae23d479c4a2d553954e1", "packages/reflex-components-dataeditor/src/reflex_components_dataeditor/dataeditor.pyi": "8e379fa038c7c6c0672639eb5902934d", "packages/reflex-components-gridjs/src/reflex_components_gridjs/datatable.pyi": "d2dc211d707c402eb24678a4cba945f7", - "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "e3ec310276f9d091fbb0261e523ca9ed", + "packages/reflex-components-lucide/src/reflex_components_lucide/icon.pyi": "b692058e40b15da293fbf463ad300a83", "packages/reflex-components-markdown/src/reflex_components_markdown/markdown.pyi": "c647559056a01c78eec616f35e930026", "packages/reflex-components-moment/src/reflex_components_moment/moment.pyi": "d6a02e447dfd3c91bba84bcd02722aed", "packages/reflex-components-plotly/src/reflex_components_plotly/plotly.pyi": "91e956633778c6992f04940c69ff7140", @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "6624297c011af5b72ff4860d29df4f10", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "d09629b81bf0df6153b131ac0ee10bd7" + "reflex/experimental/memo.pyi": "b6a139c1fba8e2a0fc97a7dcf33b5309" } diff --git a/reflex/experimental/memo.py b/reflex/experimental/memo.py new file mode 100644 index 00000000000..ea01542314d --- /dev/null +++ b/reflex/experimental/memo.py @@ -0,0 +1,11 @@ +"""Deprecated alias for :mod:`reflex_base.components.memo`.""" + +import sys + +from reflex_base.components import memo + +from reflex.experimental import ExperimentalNamespace + +ExperimentalNamespace.register_component_warning("memo") + +sys.modules[__name__] = memo # pyright: ignore[reportArgumentType] From e6278b26ed5e692ad979d1b3b656755ca1a3f915 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 16 May 2026 00:22:47 +0500 Subject: [PATCH 5/7] refactor(memo): drop Experimental prefix from memo registry and types Rename ExperimentalMemo* classes, EXPERIMENTAL_MEMOS registry, and related helpers/tests to plain Memo*/MEMOS now that memo is no longer experimental. Move integration and unit tests out of experimental/ to mirror the new module location. --- .../src/reflex_base/components/memo.py | 114 +++++++++--------- pyi_hashes.json | 2 +- reflex/compiler/compiler.py | 26 ++-- reflex/compiler/utils.py | 14 +-- reflex/testing.py | 4 +- reflex/utils/telemetry_accounting.py | 2 +- ...test_experimental_memo.py => test_memo.py} | 42 +++---- tests/integration/test_var_operations.py | 4 +- tests/units/compiler/test_memoize_plugin.py | 30 ++--- .../{experimental => components}/test_memo.py | 66 +++++----- tests/units/conftest.py | 8 +- tests/units/experimental/__init__.py | 0 tests/units/test_testing.py | 6 +- .../units/utils/test_telemetry_accounting.py | 2 +- 14 files changed, 156 insertions(+), 164 deletions(-) rename tests/integration/{test_experimental_memo.py => test_memo.py} (71%) rename tests/units/{experimental => components}/test_memo.py (93%) delete mode 100644 tests/units/experimental/__init__.py diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index 054bceb2aa2..be3238cbd53 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -1,4 +1,4 @@ -"""Experimental memo support for vars and components.""" +"""Memo support for vars and components.""" from __future__ import annotations @@ -131,8 +131,8 @@ class _MemoParamSpec: @dataclasses.dataclass(frozen=True, slots=True) -class ExperimentalMemoDefinition: - """Base metadata for an experimental memo.""" +class MemoDefinition: + """Base metadata for a memo.""" fn: Callable[..., Any] python_name: str @@ -140,7 +140,7 @@ class ExperimentalMemoDefinition: @dataclasses.dataclass(frozen=True, slots=True) -class ExperimentalMemoFunctionDefinition(ExperimentalMemoDefinition): +class MemoFunctionDefinition(MemoDefinition): """A memo that compiles to a JavaScript function.""" function: ArgsFunctionOperation @@ -148,7 +148,7 @@ class ExperimentalMemoFunctionDefinition(ExperimentalMemoDefinition): @dataclasses.dataclass(frozen=True, slots=True) -class ExperimentalMemoComponentDefinition(ExperimentalMemoDefinition): +class MemoComponentDefinition(MemoDefinition): """A memo that compiles to a React component.""" export_name: str @@ -163,14 +163,14 @@ class ExperimentalMemoComponentDefinition(ExperimentalMemoDefinition): passthrough_hole_child: Component | None = None -class ExperimentalMemoComponent(Component): - """A rendered instance of an experimental memo component.""" +class MemoComponent(Component): + """A rendered instance of a memo component.""" library = f"$/{constants.Dirs.COMPONENTS_PATH}" _memoization_mode = MemoizationMode(disposition=MemoizationDisposition.NEVER) # The user-authored component class this wrapper stands in for. Populated - # on the dynamic subclass by ``_get_experimental_memo_component_class`` so + # on the dynamic subclass by ``_get_memo_component_class`` so # introspection (e.g. compile telemetry) can recover the underlying type # without parsing the wrapper's auto-generated class name. _wrapped_component_type: ClassVar[type[Component] | None] = None @@ -178,7 +178,7 @@ class ExperimentalMemoComponent(Component): def _validate_component_children(self, children: list[Component]) -> None: """Skip direct parent/child validation for memo wrapper instances. - Experimental memos wrap an underlying compiled component definition. + Memos wrap an underlying compiled component definition. The runtime wrapper should not interpose on `_valid_parents` checks for the authored subtree because the wrapper itself is not the semantic parent in the user-authored component tree. @@ -188,7 +188,7 @@ def _validate_component_children(self, children: list[Component]) -> None: """ def _post_init(self, **kwargs): - """Initialize the experimental memo component. + """Initialize the memo component. Args: **kwargs: The kwargs to pass to the component. @@ -209,11 +209,11 @@ def _post_init(self, **kwargs): @cache -def _get_experimental_memo_component_class( +def _get_memo_component_class( export_name: str, wrapped_component_type: type[Component] = Component, -) -> type[ExperimentalMemoComponent]: - """Get the component subclass for an experimental memo export. +) -> type[MemoComponent]: + """Get the component subclass for a memo export. Class-level metadata that the compiler reads via ``type(comp)._get_*()`` (notably ``_get_app_wrap_components``, which carries providers like @@ -248,17 +248,17 @@ def _get_experimental_memo_component_class( wrapped_component_type._get_app_wrap_components ) return type( - f"ExperimentalMemoComponent_{export_name}", - (ExperimentalMemoComponent,), + f"MemoComponent_{export_name}", + (MemoComponent,), attrs, ) -EXPERIMENTAL_MEMOS: dict[str, ExperimentalMemoDefinition] = {} +MEMOS: dict[str, MemoDefinition] = {} -def _memo_registry_key(definition: ExperimentalMemoDefinition) -> str: - """Get the registry key for an experimental memo. +def _memo_registry_key(definition: MemoDefinition) -> str: + """Get the registry key for a memo. Args: definition: The memo definition. @@ -266,14 +266,14 @@ def _memo_registry_key(definition: ExperimentalMemoDefinition) -> str: Returns: The registry key for the memo. """ - if isinstance(definition, ExperimentalMemoComponentDefinition): + if isinstance(definition, MemoComponentDefinition): return definition.export_name return definition.python_name def _is_memo_reregistration( - existing: ExperimentalMemoDefinition, - definition: ExperimentalMemoDefinition, + existing: MemoDefinition, + definition: MemoDefinition, ) -> bool: """Check whether a memo definition replaces the same memo during reload. @@ -292,8 +292,8 @@ def _is_memo_reregistration( ) -def _register_memo_definition(definition: ExperimentalMemoDefinition) -> None: - """Register an experimental memo definition. +def _register_memo_definition(definition: MemoDefinition) -> None: + """Register a memo definition. Args: definition: The memo definition to register. @@ -302,18 +302,18 @@ def _register_memo_definition(definition: ExperimentalMemoDefinition) -> None: ValueError: If another memo already compiles to the same exported name. """ key = _memo_registry_key(definition) - if (existing := EXPERIMENTAL_MEMOS.get(key)) is not None and ( + if (existing := MEMOS.get(key)) is not None and ( not _is_memo_reregistration(existing, definition) ): msg = ( - f"Experimental memo name collision for `{key}`: " + f"Memo name collision for `{key}`: " f"`{existing.fn.__module__}.{existing.python_name}` and " f"`{definition.fn.__module__}.{definition.python_name}` both compile " "to the same memo name." ) raise ValueError(msg) - EXPERIMENTAL_MEMOS[key] = definition + MEMOS[key] = definition def _annotation_inner_type(annotation: Any) -> Any: @@ -446,7 +446,7 @@ def _get_rest_param(params: tuple[MemoParam, ...]) -> MemoParam | None: def _imported_function_var(name: str, return_type: Any) -> FunctionVar: - """Create the imported FunctionVar for an experimental memo. + """Create the imported FunctionVar for a memo. Args: name: The exported function name. @@ -467,7 +467,7 @@ def _imported_function_var(name: str, return_type: Any) -> FunctionVar: def _component_import_var(name: str) -> Var: - """Create the imported component var for an experimental memo component. + """Create the imported component var for a memo component. Args: name: The exported component name. @@ -1028,7 +1028,7 @@ def _classify_parameter( def _create_function_definition( fn: Callable[..., Any], return_annotation: Any, -) -> ExperimentalMemoFunctionDefinition: +) -> MemoFunctionDefinition: """Create a definition for a var-returning memo. Args: @@ -1066,7 +1066,7 @@ def _create_function_definition( return_expr=return_expr, ) - return ExperimentalMemoFunctionDefinition( + return MemoFunctionDefinition( fn=fn, python_name=fn.__name__, params=params, @@ -1080,7 +1080,7 @@ def _create_function_definition( def _create_component_definition( fn: Callable[..., Any], return_annotation: Any, -) -> ExperimentalMemoComponentDefinition: +) -> MemoComponentDefinition: """Create a definition for a component-returning memo. Args: @@ -1102,7 +1102,7 @@ def _create_component_definition( ) raise TypeError(msg) - return ExperimentalMemoComponentDefinition( + return MemoComponentDefinition( fn=fn, python_name=fn.__name__, params=params, @@ -1112,7 +1112,7 @@ def _create_component_definition( def _bind_function_runtime_args( - definition: ExperimentalMemoFunctionDefinition, + definition: MemoFunctionDefinition, *args: Any, **kwargs: Any, ) -> tuple[Any, ...]: @@ -1206,7 +1206,7 @@ def _bind_function_runtime_args( def _is_component_child(value: Any) -> bool: - """Check whether a value is valid as an experimental memo child. + """Check whether a value is valid as a memo child. Args: value: The value to check. @@ -1219,10 +1219,10 @@ def _is_component_child(value: Any) -> bool: ) -class _ExperimentalMemoFunctionWrapper: - """Callable wrapper for a var-returning experimental memo.""" +class _MemoFunctionWrapper: + """Callable wrapper for a var-returning memo.""" - def __init__(self, definition: ExperimentalMemoFunctionDefinition): + def __init__(self, definition: MemoFunctionDefinition): """Initialize the wrapper. Args: @@ -1281,10 +1281,10 @@ def _as_var(self) -> FunctionVar: return self._imported_var -class _ExperimentalMemoComponentWrapper: - """Callable wrapper for a component-returning experimental memo.""" +class _MemoComponentWrapper: + """Callable wrapper for a component-returning memo.""" - def __init__(self, definition: ExperimentalMemoComponentDefinition): + def __init__(self, definition: MemoComponentDefinition): """Initialize the wrapper. Args: @@ -1300,7 +1300,7 @@ def __init__(self, definition: ExperimentalMemoComponentDefinition): ] update_wrapper(self, definition.fn) - def __call__(self, *children: Any, **props: Any) -> ExperimentalMemoComponent: + def __call__(self, *children: Any, **props: Any) -> MemoComponent: """Call the wrapped memo and return a component. Args: @@ -1355,7 +1355,7 @@ def __call__(self, *children: Any, **props: Any) -> ExperimentalMemoComponent: raise TypeError(msg) # Build the component props passed into the memo wrapper. - return _get_experimental_memo_component_class( + return _get_memo_component_class( definition.export_name, type(definition.component) )._create( children=list(children), @@ -1374,8 +1374,8 @@ def _as_var(self) -> Var: def _create_function_wrapper( - definition: ExperimentalMemoFunctionDefinition, -) -> _ExperimentalMemoFunctionWrapper: + definition: MemoFunctionDefinition, +) -> _MemoFunctionWrapper: """Create the Python wrapper for a var-returning memo. Args: @@ -1384,12 +1384,12 @@ def _create_function_wrapper( Returns: The wrapper callable. """ - return _ExperimentalMemoFunctionWrapper(definition) + return _MemoFunctionWrapper(definition) def _create_component_wrapper( - definition: ExperimentalMemoComponentDefinition, -) -> _ExperimentalMemoComponentWrapper: + definition: MemoComponentDefinition, +) -> _MemoComponentWrapper: """Create the Python wrapper for a component-returning memo. Args: @@ -1398,19 +1398,19 @@ def _create_component_wrapper( Returns: The wrapper callable. """ - return _ExperimentalMemoComponentWrapper(definition) + return _MemoComponentWrapper(definition) def create_passthrough_component_memo( component: Component, ) -> tuple[ - Callable[..., ExperimentalMemoComponent], - ExperimentalMemoComponentDefinition, + Callable[..., MemoComponent], + MemoComponentDefinition, ]: """Create an unregistered ``@rx._x.memo``-style passthrough component memo. This is used by compiler auto-memoization so generated wrappers compile - through the experimental memo pipeline instead of emitting ad-hoc page-local + through the memo pipeline instead of emitting ad-hoc page-local ``React.memo`` declarations. The exported memo name is derived from ``component._compute_memo_tag()`` @@ -1501,7 +1501,7 @@ def passthrough(children: Var[Component]) -> Component: def memo(fn: Callable[..., Any]) -> Callable[..., Any]: - """Create an experimental memo from a function. + """Create a memo from a function. Args: fn: The function to memoize. @@ -1539,11 +1539,11 @@ def memo(fn: Callable[..., Any]) -> Callable[..., Any]: __all__ = [ - "EXPERIMENTAL_MEMOS", - "ExperimentalMemoComponent", - "ExperimentalMemoComponentDefinition", - "ExperimentalMemoDefinition", - "ExperimentalMemoFunctionDefinition", + "MEMOS", + "MemoComponent", + "MemoComponentDefinition", + "MemoDefinition", + "MemoFunctionDefinition", "create_passthrough_component_memo", "memo", ] diff --git a/pyi_hashes.json b/pyi_hashes.json index 2fb7aa54472..0104d5ccfab 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -120,5 +120,5 @@ "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", "reflex/__init__.pyi": "6624297c011af5b72ff4860d29df4f10", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "b6a139c1fba8e2a0fc97a7dcf33b5309" + "reflex/experimental/memo.pyi": "b4ad4a075ad3432fd73e8b2aabba6c44" } diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index d505984ba55..cc97455ad57 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -17,10 +17,10 @@ evaluate_style_namespaces, ) from reflex_base.components.memo import ( - EXPERIMENTAL_MEMOS, - ExperimentalMemoComponentDefinition, - ExperimentalMemoDefinition, - ExperimentalMemoFunctionDefinition, + MEMOS, + MemoComponentDefinition, + MemoDefinition, + MemoFunctionDefinition, ) from reflex_base.config import get_config from reflex_base.constants.compiler import PageNames, ResetStylesheet @@ -392,7 +392,7 @@ def _compile_component(component: Component) -> str: def _compile_memo_components( - experimental_memos: Iterable[ExperimentalMemoDefinition] = (), + memos: Iterable[MemoDefinition] = (), ) -> tuple[list[tuple[str, str]], dict[str, list[ImportVar]]]: """Compile each memo as its own module plus an empty index. @@ -405,7 +405,7 @@ def _compile_memo_components( the file at the expected path. Args: - experimental_memos: The memos to compile. + memos: The memos to compile. Returns: A list of ``(path, code)`` pairs to write — one per memo plus one @@ -416,8 +416,8 @@ def _compile_memo_components( base_dir = utils.get_memo_components_dir() - for memo in experimental_memos: - if isinstance(memo, ExperimentalMemoComponentDefinition): + for memo in memos: + if isinstance(memo, MemoComponentDefinition): memo_render, memo_imports = utils.compile_experimental_component_memo(memo) name = memo_render["name"] code, file_imports = _compile_single_memo_component( @@ -426,7 +426,7 @@ def _compile_memo_components( path = _memo_component_file_path(base_dir, name) per_memo_files.append((path, code)) _extend_imports_in_place(aggregate_imports, file_imports) - elif isinstance(memo, ExperimentalMemoFunctionDefinition): + elif isinstance(memo, MemoFunctionDefinition): memo_render, memo_imports = utils.compile_experimental_function_memo(memo) name = memo_render["name"] code, file_imports = _compile_single_memo_function( @@ -652,18 +652,18 @@ def compile_page_from_context(page_ctx: PageContext) -> tuple[str, str]: def compile_memo_components( - experimental_memos: Iterable[ExperimentalMemoDefinition] = (), + memos: Iterable[MemoDefinition] = (), ) -> tuple[list[tuple[str, str]], dict[str, list[ImportVar]]]: """Compile the memos into one module per memo plus an index. Args: - experimental_memos: The memos to compile. + memos: The memos to compile. Returns: A list of ``(path, code)`` pairs (one per memo module and one index) alongside the aggregated imports across all memo modules. """ - return _compile_memo_components(experimental_memos) + return _compile_memo_components(memos) def purge_web_pages_dir(): @@ -1102,7 +1102,7 @@ def compile_app( memo_component_files, memo_components_imports = compile_memo_components( ( - *tuple(EXPERIMENTAL_MEMOS.values()), + *tuple(MEMOS.values()), *tuple(compile_ctx.auto_memo_components.values()), ), ) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 939ada0d033..a5de6cea134 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -16,8 +16,8 @@ from reflex_base import constants from reflex_base.components.component import Component, ComponentStyle from reflex_base.components.memo import ( - ExperimentalMemoComponentDefinition, - ExperimentalMemoFunctionDefinition, + MemoComponentDefinition, + MemoFunctionDefinition, MemoParamKind, ) from reflex_base.constants.state import FIELD_MARKER @@ -375,9 +375,9 @@ def _app_style() -> ComponentStyle | Style: def compile_experimental_component_memo( - definition: ExperimentalMemoComponentDefinition, + definition: MemoComponentDefinition, ) -> tuple[dict, ParsedImportDict]: - """Compile an experimental memo component. + """Compile a memo component. Args: definition: The component memo definition. @@ -420,7 +420,7 @@ def compile_experimental_component_memo( dynamic_imports = render._get_all_dynamic_imports() all_imports = render._get_all_imports() - # Each experimental memo now lives in ``web/utils/components/.jsx``, + # Each memo now lives in ``web/utils/components/.jsx``, # so importing the ``$/utils/components`` index from this file is only # circular when ```` itself appears in that index — i.e. a legacy # ``@rx.memo`` wrapper file. For auto-memo wrappers around legacy custom @@ -517,9 +517,9 @@ def _root_only_dynamic_imports(component: Component) -> set[str]: def compile_experimental_function_memo( - definition: ExperimentalMemoFunctionDefinition, + definition: MemoFunctionDefinition, ) -> tuple[dict, ParsedImportDict]: - """Compile an experimental memo function. + """Compile a memo function. Args: definition: The function memo definition. diff --git a/reflex/testing.py b/reflex/testing.py index 783f7cb2771..0a8e87898b3 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar import uvicorn -from reflex_base.components.memo import EXPERIMENTAL_MEMOS +from reflex_base.components.memo import MEMOS from reflex_base.config import get_config from reflex_base.environment import environment from reflex_base.registry import RegistrationContext @@ -241,7 +241,7 @@ def _initialize_app(self): os.environ["REFLEX_TELEMETRY_ENABLED"] = "false" # Reset the global memo registry so previous AppHarness apps do not # leak compiled component definitions into the next test app. - EXPERIMENTAL_MEMOS.clear() + MEMOS.clear() self.app_path.mkdir(parents=True, exist_ok=True) if self.app_source is not None: app_globals = self._get_globals_from_signature(self.app_source) diff --git a/reflex/utils/telemetry_accounting.py b/reflex/utils/telemetry_accounting.py index 29f1971f3b7..28cba610596 100644 --- a/reflex/utils/telemetry_accounting.py +++ b/reflex/utils/telemetry_accounting.py @@ -95,7 +95,7 @@ def _count_components(pages: Iterable[BaseComponent]) -> dict[str, int]: """Count component types across one or more component trees. Auto-memoized components live in the tree as dynamic - ``ExperimentalMemoComponent___`` subclasses. Bucketing by + ``MemoComponent___`` subclasses. Bucketing by the raw class name would explode telemetry cardinality (each handler hash produces a new key), so wrappers are counted under the user-authored component they stand in for, exposed via ``_wrapped_component_type``. diff --git a/tests/integration/test_experimental_memo.py b/tests/integration/test_memo.py similarity index 71% rename from tests/integration/test_experimental_memo.py rename to tests/integration/test_memo.py index 935f8c75ce6..e0b969d1e59 100644 --- a/tests/integration/test_experimental_memo.py +++ b/tests/integration/test_memo.py @@ -8,7 +8,7 @@ from reflex.testing import AppHarness -def ExperimentalMemoApp(): +def MemoApp(): """Reflex app that exercises experimental memo functions and components.""" import reflex as rx @@ -41,7 +41,7 @@ def summary_card( rest, ) - class ExperimentalMemoState(rx.State): + class MemoState(rx.State): amount: int = 125 currency: str = "USD" title: str = "Current Price" @@ -52,8 +52,8 @@ def increment_amount(self): def index() -> rx.Component: formatted_price = format_price( - amount=ExperimentalMemoState.amount, - currency=ExperimentalMemoState.currency, + amount=MemoState.amount, + currency=MemoState.currency, ) return rx.vstack( rx.vstack( @@ -65,11 +65,11 @@ def index() -> rx.Component: rx.button( "Increment", id="increment-price", - on_click=ExperimentalMemoState.increment_amount, + on_click=MemoState.increment_amount, ), summary_card( rx.text("Children are passed positionally.", id="summary-child"), - title=ExperimentalMemoState.title, + title=MemoState.title, value=formatted_price, id="summary-card", class_name="forwarded-summary-card", @@ -81,8 +81,8 @@ def index() -> rx.Component: @pytest.fixture -def experimental_memo_app(tmp_path) -> Generator[AppHarness, None, None]: - """Start ExperimentalMemoApp app at tmp_path via AppHarness. +def memo_app(tmp_path) -> Generator[AppHarness, None, None]: + """Start MemoApp app at tmp_path via AppHarness. Args: tmp_path: pytest tmp_path fixture. @@ -92,34 +92,31 @@ def experimental_memo_app(tmp_path) -> Generator[AppHarness, None, None]: """ with AppHarness.create( root=tmp_path, - app_source=ExperimentalMemoApp, + app_source=MemoApp, ) as harness: yield harness -def test_experimental_memo_app(experimental_memo_app: AppHarness): +def test_memo_app(memo_app: AppHarness): """Render experimental memos and assert on their frontend behavior. Args: - experimental_memo_app: Harness for ExperimentalMemoApp. + memo_app: Harness for MemoApp. """ - assert experimental_memo_app.app_instance is not None, "app is not running" - driver = experimental_memo_app.frontend() + assert memo_app.app_instance is not None, "app is not running" + driver = memo_app.frontend() memo_custom_code_stack = AppHarness.poll_for_or_raise_timeout( lambda: driver.find_element(By.ID, "experimental-memo-custom-code") ) assert ( - experimental_memo_app.poll_for_content(memo_custom_code_stack, exp_not_equal="") + memo_app.poll_for_content(memo_custom_code_stack, exp_not_equal="") == "foobarbarbar" ) assert memo_custom_code_stack.text == "foobarbarbar" formatted_price = driver.find_element(By.ID, "formatted-price") - assert ( - experimental_memo_app.poll_for_content(formatted_price, exp_not_equal="") - == "USD: $125" - ) + assert memo_app.poll_for_content(formatted_price, exp_not_equal="") == "USD: $125" summary_card = driver.find_element(By.ID, "summary-card") assert "forwarded-summary-card" in (summary_card.get_attribute("class") or "") @@ -130,11 +127,8 @@ def test_experimental_memo_app(experimental_memo_app: AppHarness): ) summary_value = driver.find_element(By.ID, "summary-value") - assert ( - experimental_memo_app.poll_for_content(summary_value, exp_not_equal="") - == "USD: $125" - ) + assert memo_app.poll_for_content(summary_value, exp_not_equal="") == "USD: $125" driver.find_element(By.ID, "increment-price").click() - assert experimental_memo_app.poll_for_content(formatted_price) == "USD: $130" - assert experimental_memo_app.poll_for_content(summary_value) == "USD: $130" + assert memo_app.poll_for_content(formatted_price) == "USD: $130" + assert memo_app.poll_for_content(summary_value) == "USD: $130" diff --git a/tests/integration/test_var_operations.py b/tests/integration/test_var_operations.py index 383bafa1eb7..5b5949188ef 100644 --- a/tests/integration/test_var_operations.py +++ b/tests/integration/test_var_operations.py @@ -66,9 +66,7 @@ def memo_comp( @rx.memo def memo_comp_nested(int_var2: rx.Var[int], id: rx.Var[str]) -> rx.Component: - return memo_comp( - list1=rx.Var.create([3, 4]), int_var1=int_var2, id=id - ) + return memo_comp(list1=rx.Var.create([3, 4]), int_var1=int_var2, id=id) @app.add_page def index(): diff --git a/tests/units/compiler/test_memoize_plugin.py b/tests/units/compiler/test_memoize_plugin.py index 80ce121627b..aaef5732be9 100644 --- a/tests/units/compiler/test_memoize_plugin.py +++ b/tests/units/compiler/test_memoize_plugin.py @@ -11,8 +11,8 @@ from reflex_base.components.component import Component from reflex_base.components.component import field as component_field from reflex_base.components.memo import ( - ExperimentalMemoComponent, - ExperimentalMemoComponentDefinition, + MemoComponent, + MemoComponentDefinition, create_passthrough_component_memo, ) from reflex_base.components.memoize_helpers import ( @@ -198,7 +198,7 @@ def test_should_not_memoize_when_disposition_never() -> None: assert not _should_memoize(comp) -def test_memoize_wrapper_uses_experimental_memo_component_and_call_site() -> None: +def test_memoize_wrapper_uses_memo_component_and_call_site() -> None: """Memoizable component imports a generated ``rx._x.memo`` wrapper.""" ctx, page_ctx = _compile_single_page(lambda: Plain.create(STATE_VAR)) @@ -327,7 +327,7 @@ def special_child() -> Component: ctx, page_ctx = _compile_single_page(lambda: rx.box(special_child())) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) memo_code = "\n".join(code for _, code in memo_files) @@ -368,7 +368,7 @@ def accordion() -> Component: ) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) foreach_code = next( code for path, code in memo_files if "/Foreach" in path or "\\Foreach" in path @@ -401,7 +401,7 @@ def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None: wrapped_definitions = [ definition for definition in ctx.auto_memo_components.values() - if isinstance(definition, ExperimentalMemoComponentDefinition) + if isinstance(definition, MemoComponentDefinition) ] wrapped_types = {type(definition.component) for definition in wrapped_definitions} @@ -493,7 +493,7 @@ def test_generated_memo_component_is_not_itself_memoized() -> None: """The generated memo component instance itself is skipped by the heuristic.""" wrapper_factory, _definition = create_passthrough_component_memo(Fragment.create()) wrapper = wrapper_factory(Plain.create()) - assert isinstance(wrapper, ExperimentalMemoComponent) + assert isinstance(wrapper, MemoComponent) assert not _should_memoize(wrapper) @@ -541,7 +541,7 @@ def test_generated_memo_component_renders_as_its_exported_tag() -> None: """The generated experimental memo component renders as its exported tag.""" wrapper_factory, definition = create_passthrough_component_memo(Fragment.create()) wrapper = wrapper_factory(Plain.create()) - assert isinstance(wrapper, ExperimentalMemoComponent) + assert isinstance(wrapper, MemoComponent) tag = definition.export_name assert tag.startswith("Fragment_"), ( f"Expected the wrapped class qualname to be encoded in the tag prefix; " @@ -773,7 +773,7 @@ def create(cls, *children, **props): "expected an auto-memo wrapper to be generated for the leaf" ) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) memo_code = "\n".join(code for _, code in memo_files) assert "useLeafProbe" in memo_code, ( @@ -1016,7 +1016,7 @@ def page() -> Component: ) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) memo_code = "\n".join(code for _, code in memo_files) @@ -1113,7 +1113,7 @@ def page() -> Component: ) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) match_memo_code = next( code @@ -1225,7 +1225,7 @@ def page() -> Component: wrapper_tag = next(iter(ctx.memoize_wrappers)) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) memo_code = next( code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx" @@ -1308,7 +1308,7 @@ def page() -> Component: ) memo_files, _memo_imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) memo_code = next( code for path, code in memo_files if Path(path).name == f"{wrapper_tag}.jsx" @@ -1656,7 +1656,7 @@ def _compile_memo_module_text(ctx: CompileContext) -> str: from reflex.compiler.compiler import compile_memo_components memo_files, _imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) return "\n".join(code for _, code in memo_files) @@ -2176,7 +2176,7 @@ def test_each_memo_wrapper_emits_one_component_module_file() -> None: ) ) memo_files, _imports = compile_memo_components( - experimental_memos=tuple(ctx.auto_memo_components.values()), + memos=tuple(ctx.auto_memo_components.values()), ) component_module_names = { Path(path).name diff --git a/tests/units/experimental/test_memo.py b/tests/units/components/test_memo.py similarity index 93% rename from tests/units/experimental/test_memo.py rename to tests/units/components/test_memo.py index 7fcb34594f1..85e90b53499 100644 --- a/tests/units/experimental/test_memo.py +++ b/tests/units/components/test_memo.py @@ -9,10 +9,10 @@ from reflex_base.components.component import Component from reflex_base.components.memo import ( _SPECS, - EXPERIMENTAL_MEMOS, - ExperimentalMemoComponent, - ExperimentalMemoComponentDefinition, - ExperimentalMemoFunctionDefinition, + MEMOS, + MemoComponent, + MemoComponentDefinition, + MemoFunctionDefinition, MemoParam, MemoParamKind, _MemoCallBinding, @@ -55,8 +55,8 @@ def format_price(amount: rx.Var[int], currency: rx.Var[str]) -> rx.Var[str]: ) assert isinstance(format_price._as_var(), FunctionVar) - definition = EXPERIMENTAL_MEMOS["format_price"] - assert isinstance(definition, ExperimentalMemoFunctionDefinition) + definition = MEMOS["format_price"] + assert isinstance(definition, MemoFunctionDefinition) assert ( str(definition.function) == '((amount, currency) => ((currency+": $")+amount))' ) @@ -90,7 +90,7 @@ def my_card( ) component_again = my_card(title="World") - assert isinstance(component, ExperimentalMemoComponent) + assert isinstance(component, MemoComponent) assert len(component.children) == 2 assert component.get_props() == ("title", "foo") assert type(component) is type(component_again) @@ -103,11 +103,11 @@ def my_card( assert 'foo:"extra"' in rendered["props"] assert 'className:"extra"' in rendered["props"] - definition = EXPERIMENTAL_MEMOS["MyCard"] - assert isinstance(definition, ExperimentalMemoComponentDefinition) + definition = MEMOS["MyCard"] + assert isinstance(definition, MemoComponentDefinition) assert any(str(prop) == "rest" for prop in definition.component.special_props) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const MyCard = memo(({children, title:title" in code assert "...rest" in code @@ -125,13 +125,13 @@ def conditional_slot( ) -> rx.Var[rx.Component]: return rx.cond(show, first, second) - definition = EXPERIMENTAL_MEMOS["ConditionalSlot"] - assert isinstance(definition, ExperimentalMemoComponentDefinition) + definition = MEMOS["ConditionalSlot"] + assert isinstance(definition, MemoComponentDefinition) assert definition.component.render() == { "contents": "(showRxMemo ? firstRxMemo : secondRxMemo)" } - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const ConditionalSlot = memo(({show:showRxMemo" in code assert "(showRxMemo ? firstRxMemo : secondRxMemo)" in code @@ -155,7 +155,7 @@ def merge_styles( assert '["color"] : "red"' in str(merged) assert '["className"] : "primary"' in str(merged) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert ( "export const merge_styles = (({base, ...overrides}) => ({...base, ...overrides}));" @@ -173,7 +173,7 @@ def test_component_returning_memo_with_only_rest(): def hover_trigger(rest: rx.RestProp) -> rx.Component: return rx.text("hover me", rest) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "memo(({...rest})" in code assert "({," not in code @@ -186,7 +186,7 @@ def test_var_returning_memo_with_only_rest(): def merge_only(overrides: rx.RestProp) -> rx.Var[Any]: return overrides - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "(({...overrides}) => overrides)" in code assert "({," not in code @@ -214,7 +214,7 @@ def label_slot( assert '["children"]' in str(rendered) assert '["className"] : "slot"' in str(rendered) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const label_slot = (({children, label, ...rest}) => label);" in code @@ -262,7 +262,7 @@ def test_memo_rejects_component_and_function_name_collision(): def foo_bar() -> rx.Component: return rx.box() - assert "FooBar" in EXPERIMENTAL_MEMOS + assert "FooBar" in MEMOS with pytest.raises(ValueError, match=r"name collision.*FooBar"): @@ -384,7 +384,7 @@ def format_price(amount: rx.Var[int], currency: rx.Var[str]) -> rx.Var[str]: def my_card(children: rx.Var[rx.Component], *, title: rx.Var[str]) -> rx.Component: return rx.box(rx.heading(title), children) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "export const TextWrapper = memo(" in code @@ -401,7 +401,7 @@ def noop() -> None: pass memos = tuple( - ExperimentalMemoComponentDefinition( + MemoComponentDefinition( fn=noop, python_name=f"memo_{idx}", params=(), @@ -413,7 +413,7 @@ def noop() -> None: ) def fake_compile_experimental_component_memo( - definition: ExperimentalMemoComponentDefinition, + definition: MemoComponentDefinition, ) -> tuple[dict[str, str], dict[str, list[ImportVar]]]: return {"name": definition.export_name}, {} @@ -469,8 +469,8 @@ def wrapper() -> rx.Component: assert "inner" not in experimental_component._get_all_imports() - definition = EXPERIMENTAL_MEMOS["Wrapper"] - assert isinstance(definition, ExperimentalMemoComponentDefinition) + definition = MEMOS["Wrapper"] + assert isinstance(definition, MemoComponentDefinition) _, imports = compiler_utils.compile_experimental_component_memo(definition) assert "inner" in imports @@ -484,8 +484,8 @@ def test_compile_experimental_component_memo_does_not_mutate_definition( def wrapper() -> rx.Component: return rx.box("hi") - definition = EXPERIMENTAL_MEMOS["Wrapper"] - assert isinstance(definition, ExperimentalMemoComponentDefinition) + definition = MEMOS["Wrapper"] + assert isinstance(definition, MemoComponentDefinition) assert definition.component.style == Style() monkeypatch.setattr( @@ -522,7 +522,7 @@ def transparent(children: rx.Var[rx.Component]) -> rx.Component: wrapped_child = transparent(RestrictedChild.create()) parent = ValidParent.create(wrapped_child) - assert isinstance(wrapped_child, ExperimentalMemoComponent) + assert isinstance(wrapped_child, MemoComponent) assert parent.children == [wrapped_child] @@ -539,7 +539,7 @@ def add_custom_code(self) -> list[str]: def foo_component(label: rx.Var[str]) -> rx.Component: return FooComponent.create(label, rx.Var("foo")) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) assert "const foo = 'bar'" in code @@ -558,8 +558,8 @@ def eh_memo( rx.input(on_change=event), ) - definition = EXPERIMENTAL_MEMOS["EhMemo"] - assert isinstance(definition, ExperimentalMemoComponentDefinition) + definition = MEMOS["EhMemo"] + assert isinstance(definition, MemoComponentDefinition) event_param = next(p for p in definition.params if p.name == "event") assert event_param.kind is MemoParamKind.EVENT_TRIGGER assert event_param.kind_data is not None @@ -573,8 +573,8 @@ def test_component_memo_accepts_bare_event_handler(): def bare_eh_memo(event: rx.EventHandler) -> rx.Component: return rx.button("click", on_click=event()) - definition = EXPERIMENTAL_MEMOS["BareEhMemo"] - assert isinstance(definition, ExperimentalMemoComponentDefinition) + definition = MEMOS["BareEhMemo"] + assert isinstance(definition, MemoComponentDefinition) event_param = next(p for p in definition.params if p.name == "event") assert event_param.kind is MemoParamKind.EVENT_TRIGGER assert event_param.kind_data is no_args_event_spec @@ -593,7 +593,7 @@ def eh_compile_memo( rx.input(on_change=event), ) - files, _ = compiler.compile_memo_components(tuple(EXPERIMENTAL_MEMOS.values())) + files, _ = compiler.compile_memo_components(tuple(MEMOS.values())) code = "\n".join(c for _, c in files) # Signature destructures the EH prop with the RxMemo suffix. @@ -622,7 +622,7 @@ def eh_wired_memo( return rx.button(some_value, on_click=event(some_value)) component = eh_wired_memo(some_value="hello", event=raw_handler) - assert isinstance(component, ExperimentalMemoComponent) + assert isinstance(component, MemoComponent) # EH props live on event_triggers, not in get_props(). assert "event" not in component.get_props() assert "event" in component.event_triggers diff --git a/tests/units/conftest.py b/tests/units/conftest.py index 477496dd98a..acc05045bfe 100644 --- a/tests/units/conftest.py +++ b/tests/units/conftest.py @@ -10,7 +10,7 @@ import pytest import pytest_asyncio -from reflex_base.components.memo import EXPERIMENTAL_MEMOS +from reflex_base.components.memo import MEMOS from reflex_base.event import Event, EventSpec from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor, EventProcessor @@ -495,9 +495,9 @@ def preserve_memo_registries(): Yields: None """ - experimental_memos = dict(EXPERIMENTAL_MEMOS) + memos = dict(MEMOS) try: yield finally: - EXPERIMENTAL_MEMOS.clear() - EXPERIMENTAL_MEMOS.update(experimental_memos) + MEMOS.clear() + MEMOS.update(memos) diff --git a/tests/units/experimental/__init__.py b/tests/units/experimental/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/units/test_testing.py b/tests/units/test_testing.py index 1fe436c619b..82a40a2dd26 100644 --- a/tests/units/test_testing.py +++ b/tests/units/test_testing.py @@ -6,7 +6,7 @@ import pytest import reflex_base.config -from reflex_base.components.memo import EXPERIMENTAL_MEMOS +from reflex_base.components.memo import MEMOS from reflex_base.constants import IS_WINDOWS import reflex.reflex as reflex_cli @@ -97,7 +97,7 @@ def test_app_harness_initialize_clears_memo_registries( """ monkeypatch.setattr(reflex_cli, "_init", lambda **kwargs: None) - EXPERIMENTAL_MEMOS["format_value"] = mock.sentinel.memo + MEMOS["format_value"] = mock.sentinel.memo harness = AppHarness.create( root=tmp_path / "memo_app", @@ -107,7 +107,7 @@ def test_app_harness_initialize_clears_memo_registries( harness.app_module_path.parent.mkdir(parents=True, exist_ok=True) harness._initialize_app() - assert "format_value" not in EXPERIMENTAL_MEMOS + assert "format_value" not in MEMOS harness_mocks.get_and_validate_app.assert_called_once_with(reload=True) diff --git a/tests/units/utils/test_telemetry_accounting.py b/tests/units/utils/test_telemetry_accounting.py index d36f6ee2891..8c5bd3d41cc 100644 --- a/tests/units/utils/test_telemetry_accounting.py +++ b/tests/units/utils/test_telemetry_accounting.py @@ -196,7 +196,7 @@ def test_memo_wrapper_class_records_wrapped_component_type(): memo_module = importlib.import_module("reflex.experimental.memo") - wrapper_cls = memo_module._get_experimental_memo_component_class( + wrapper_cls = memo_module._get_memo_component_class( "Button_button_deadbeefcafebabe", Button, ) From 0afce5c5e9c3e8b64b3ad91d3fcfe267f712c1a4 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 16 May 2026 00:25:40 +0500 Subject: [PATCH 6/7] spellfix --- docs/library/other/memo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/library/other/memo.md b/docs/library/other/memo.md index 75b9286c9f4..362d5122d68 100644 --- a/docs/library/other/memo.md +++ b/docs/library/other/memo.md @@ -70,7 +70,7 @@ def index(): ## Forwarding Props with `rx.RestProp` -Use `rx.RestProp` to accept and forward arbitrary props (think `...rest` in JSX). Useful for thin wrappers that re-style a primitive without re-declaring every prop. +Use `rx.RestProp` to accept and forward arbitrary props (think `...rest` in JSX). Useful for thin wrappers that re-style a primitive without redeclaring every prop. ```python @rx.memo From d5650cff2efb63289a18ad49513892df5f314acc Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Sat, 16 May 2026 01:24:08 +0500 Subject: [PATCH 7/7] feat(vars): expose EMPTY_VAR_STR and EMPTY_VAR_INT as memo-friendly defaults Reusable Var-typed empty-value constants so memo signatures can spell strict defaults without per-call-site `Var.create(...)` (which trips B008) or bespoke module-level singletons. Updates the memo doc and the in-tree memos that previously rolled their own empty Var. --- docs/library/other/memo.md | 2 +- packages/reflex-base/src/reflex_base/vars/__init__.py | 4 ++++ packages/reflex-base/src/reflex_base/vars/base.py | 6 +++++- .../components/base/skeleton.py | 4 ++-- .../components/base/theme_switcher.py | 6 +++--- .../components/icons/others.py | 10 ++++------ .../src/reflex_site_shared/views/footer.py | 3 ++- pyi_hashes.json | 2 +- reflex/__init__.py | 2 +- tests/units/components/test_memo.py | 2 +- 10 files changed, 24 insertions(+), 17 deletions(-) diff --git a/docs/library/other/memo.md b/docs/library/other/memo.md index 362d5122d68..a3415b6cd00 100644 --- a/docs/library/other/memo.md +++ b/docs/library/other/memo.md @@ -15,7 +15,7 @@ Every parameter must be annotated with `rx.Var[...]` or `rx.RestProp`. The compi 3. **`rx.Var[rx.Component]` for slot children** — a parameter named `children` annotated as `rx.Var[rx.Component]` accepts children rendered by the caller. 4. **Keyword arguments at the call site** — pass props by name, not by position. -Defaults work normally: `class_name: rx.Var[str] = ""` falls back to `""` when the caller omits the prop. +Defaults need to be `rx.Var` values. For the common empty cases use the module-level constants `rx.EMPTY_VAR_STR` (an empty string) and `rx.EMPTY_VAR_INT` (zero): `class_name: rx.Var[str] = rx.EMPTY_VAR_STR` falls back to `""` when the caller omits the prop. ## Basic Usage diff --git a/packages/reflex-base/src/reflex_base/vars/__init__.py b/packages/reflex-base/src/reflex_base/vars/__init__.py index 4c0ebe85c94..c986cf1fd36 100644 --- a/packages/reflex-base/src/reflex_base/vars/__init__.py +++ b/packages/reflex-base/src/reflex_base/vars/__init__.py @@ -2,6 +2,8 @@ from . import base, color, datetime, function, number, object, sequence from .base import ( + EMPTY_VAR_INT, + EMPTY_VAR_STR, BaseStateMeta, EvenMoreBasicBaseState, Field, @@ -28,6 +30,8 @@ ) __all__ = [ + "EMPTY_VAR_INT", + "EMPTY_VAR_STR", "ArrayVar", "BaseStateMeta", "BooleanVar", diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 4034b69aa3c..56760594f84 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -362,7 +362,7 @@ def can_use_in_object_var(cls: GenericType) -> bool: Whether the class can be used in an ObjectVar. """ if types.is_union(cls): - return all(can_use_in_object_var(t) for t in types.get_args(cls)) + return all(can_use_in_object_var(t) for t in get_args(cls)) return ( isinstance(cls, type) and not safe_issubclass(cls, Var) @@ -3691,3 +3691,7 @@ def add_field(cls, name: str, var: Var, default_value: Any): annotated_type=var._var_type, ) cls.__fields__[name] = new_field + + +EMPTY_VAR_STR: Var[str] = LiteralVar.create("") +EMPTY_VAR_INT: Var[int] = LiteralVar.create(0) diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py b/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py index 5cd2f216405..3207225b0f5 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/base/skeleton.py @@ -4,7 +4,7 @@ from reflex.components.component import Component from reflex.components.memo import memo -from reflex.vars.base import Var +from reflex.vars.base import EMPTY_VAR_STR, Var from reflex_components_internal.utils.twmerge import cn @@ -16,7 +16,7 @@ class ClassNames: @memo def skeleton_component( - class_name: Var[str] = "", + class_name: Var[str] = EMPTY_VAR_STR, ) -> Component: """Skeleton component. diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py b/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py index 8dfe783b480..51826659d02 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/base/theme_switcher.py @@ -7,7 +7,7 @@ from reflex.components.component import Component from reflex.components.memo import memo from reflex.style import LiteralColorMode, color_mode, set_color_mode -from reflex.vars.base import Var +from reflex.vars.base import EMPTY_VAR_STR, Var from reflex_components_internal.components.icons.hugeicon import hi from reflex_components_internal.utils.twmerge import cn @@ -31,7 +31,7 @@ def theme_switcher_item(mode: LiteralColorMode, icon: str) -> Component: ) -def theme_switcher(class_name: str = "") -> Component: +def theme_switcher(class_name: str | Var[str] = "") -> Component: """Theme switcher component. Returns: @@ -49,7 +49,7 @@ def theme_switcher(class_name: str = "") -> Component: @memo -def memoized_theme_switcher(class_name: Var[str] = "") -> Component: +def memoized_theme_switcher(class_name: Var[str] = EMPTY_VAR_STR) -> Component: """Memoized theme switcher component. Returns: diff --git a/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py b/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py index e513590df74..b187b08914a 100644 --- a/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py +++ b/packages/reflex-components-internal/src/reflex_components_internal/components/icons/others.py @@ -4,16 +4,14 @@ from reflex.components.component import Component from reflex.components.memo import memo -from reflex.vars.base import Var +from reflex.vars.base import EMPTY_VAR_STR, Var from reflex_components_internal.components.icons.hugeicon import hi from reflex_components_internal.utils.twmerge import cn -_EMPTY_STR_VAR: Var[str] = Var.create("") - @memo def spinner_component( - class_name: Var[str] = _EMPTY_STR_VAR, + class_name: Var[str] = EMPTY_VAR_STR, ) -> Component: """Create a spinner SVG icon. @@ -47,7 +45,7 @@ def spinner_component( @memo def select_arrow_icon( - class_name: Var[str] = _EMPTY_STR_VAR, + class_name: Var[str] = EMPTY_VAR_STR, ) -> Component: """A select arrow SVG icon. @@ -61,7 +59,7 @@ def select_arrow_icon( @memo -def arrow_svg_component(class_name: Var[str] = _EMPTY_STR_VAR) -> Component: +def arrow_svg_component(class_name: Var[str] = EMPTY_VAR_STR) -> Component: """Create a tooltip arrow SVG icon. The arrow SVG icon. diff --git a/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py b/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py index 40ab5b37643..69d80a90d4b 100644 --- a/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py +++ b/packages/reflex-site-shared/src/reflex_site_shared/views/footer.py @@ -232,7 +232,8 @@ def footer_legal(class_name: str = "") -> rx.Component: @rx.memo def footer_index( - class_name: rx.Var[str] = "", grid_class_name: rx.Var[str] = "" + class_name: rx.Var[str] = rx.EMPTY_VAR_STR, + grid_class_name: rx.Var[str] = rx.EMPTY_VAR_STR, ) -> rx.Component: """Full marketing footer: logo, newsletter, links, and legal. diff --git a/pyi_hashes.json b/pyi_hashes.json index 0104d5ccfab..247e5c0a1ef 100644 --- a/pyi_hashes.json +++ b/pyi_hashes.json @@ -118,7 +118,7 @@ "packages/reflex-components-recharts/src/reflex_components_recharts/polar.pyi": "1979bb6c22bb7a0d3342b2d63fb19d74", "packages/reflex-components-recharts/src/reflex_components_recharts/recharts.pyi": "c5288f311fe37b23539518ba2a3d4482", "packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7", - "reflex/__init__.pyi": "6624297c011af5b72ff4860d29df4f10", + "reflex/__init__.pyi": "12a863ddbcac050c702a3ec6092ae17c", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", "reflex/experimental/memo.pyi": "b4ad4a075ad3432fd73e8b2aabba6c44" } diff --git a/reflex/__init__.py b/reflex/__init__.py index d64f587525e..6e711166871 100644 --- a/reflex/__init__.py +++ b/reflex/__init__.py @@ -235,7 +235,7 @@ "utils.imports": ["ImportDict", "ImportVar"], "utils.misc": ["run_in_thread"], "utils.serializers": ["serializer"], - "vars": ["Var", "field", "Field", "RestProp"], + "vars": ["Var", "field", "Field", "RestProp", "EMPTY_VAR_STR", "EMPTY_VAR_INT"], } _SUBMODULES: set[str] = { diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 85e90b53499..720ca07f7ec 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -220,7 +220,7 @@ def label_slot( def test_memo_requires_var_annotations(): - """Experimental memos should require Var annotations on parameters.""" + """Memos should require Var annotations on parameters.""" with pytest.raises(TypeError, match="must be annotated"): @rx._x.memo