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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/library/other/memo.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,32 @@ def primary_button(
return rx.button(label, rest.merge({"class_name": class_name}))
```

### Limitation: props consumed at build time

`rx.RestProp` forwards props to the rendered element at **runtime**, so it can only carry props the element itself understands — real component props and CSS props. It **cannot** carry a prop that the target component's `create()` consumes at **build time** to decide what gets rendered.

The memo body runs once, when the app compiles — before any caller has passed a value. A value sent later through `rest` arrives only in the browser, after the target's `create()` has already run, so it never reaches that code. It is then emitted as a plain prop or as CSS and silently has no effect. Build-time props include `is_external` on `rx.link` (it selects a router link), the `tag` on `rx.icon` (it picks which icon to import), and any custom `create()` that consumes a keyword to reshape its output.

To forward such a prop, give it its own `rx.Var[...]` parameter and place it in the body yourself instead of routing it through `rest`:

```python
class CustomText(rx.el.Span):
@classmethod
def create(cls, *children, prefix: rx.Var[str] | str = "", **props) -> rx.Component:
return super().create(prefix, *children, **props)


# `prefix` is consumed by `create`, so it cannot arrive through `rest`.
# Declaring it as a parameter lets the memo body pass it to `create` itself.
@rx.memo
def styled_text(rest: rx.RestProp, *, prefix: rx.Var[str]) -> rx.Component:
return CustomText.create("Foo", rest, prefix=prefix)


def index():
return styled_text(prefix="P: ", class_name="c")
```


## Accepting Children

Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/news/6605.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`@rx.memo` functions that forward props through `rx.RestProp` now classify those props the same way a regular component does: a forwarded prop that is not a declared prop of the target (e.g. `font_weight=`) is routed into the component's `css` instead of being passed through as an unrecognized prop and silently dropped. Props the target actually declares are still forwarded normally.
65 changes: 55 additions & 10 deletions packages/reflex-base/src/reflex_base/components/memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
)
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.style import convert_dict_to_style_and_format_emotion
from reflex_base.utils import console, format
from reflex_base.utils.imports import ImportVar
from reflex_base.utils.types import safe_issubclass, typehint_issubclass
Expand Down Expand Up @@ -286,6 +287,11 @@ class MemoComponentDefinition(MemoDefinition):
# imports collection, so descendants emit their refs/imports/hooks in the
# page scope rather than being duplicated inside the memo body.
passthrough_hole_child: Component | None = None
# Field names of the component(s) the body spreads an ``rx.RestProp`` onto,
# populated as a side effect of evaluating the body (``_lift_rest_props``).
# A forwarded prop that is not one of these is a CSS prop, so the call site
# routes it into ``css`` exactly like a non-memo component would.
_rest_target_fields: set[str] = dataclasses.field(default_factory=set)

@property
def component(self) -> Component:
Expand All @@ -296,6 +302,18 @@ def component(self) -> Component:
"""
return self._component.get()

def rest_target_field_names(self) -> set[str]:
"""Field names of the body's ``rx.RestProp`` target(s).

Forces body evaluation so the set is populated before the first call
site needs it.

Returns:
The union of the rest target components' declared field names.
"""
self._component.get()
return self._rest_target_fields


class MemoComponent(Component):
"""A rendered instance of a memo component."""
Expand Down Expand Up @@ -334,7 +352,11 @@ def _post_init(self, **kwargs):
param.bind_call_value(binding)

has_rest = _get_rest_param(definition.params) is not None
rest_props = binding.take_rest(self.get_fields()) if has_rest else {}
rest_props = (
binding.take_rest(self.get_fields(), definition.rest_target_field_names())
if has_rest
else {}
)

super()._post_init(**binding.build_super_kwargs())

Expand Down Expand Up @@ -965,13 +987,22 @@ def add_event_trigger(self, js_prop_name: str, value: Any, args_spec: Any) -> No
value=value, args_spec=args_spec, key=js_prop_name
)

def take_rest(self, component_fields: Mapping[str, Any]) -> dict[str, Any]:
def take_rest(
self, component_fields: Mapping[str, Any], rest_target_fields: set[str]
) -> dict[str, Any]:
rest: dict[str, Any] = {}
css: 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)
value = self.raw_kwargs.pop(key)
if key in rest_target_fields:
rest[format.to_camel_case(key)] = LiteralVar.create(value)
else:
css[key] = value
if css:
rest["css"] = LiteralVar.create(
convert_dict_to_style_and_format_emotion(css)
)
Comment on lines +1003 to 1006

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 convert_dict_to_style_and_format_emotion can return None

format_as_emotion returns None when its internal emotion_style dict ends up empty after processing (e.g. all entries are filtered or a Var-only style resolves to nothing). Passing None to LiteralVar.create would produce a css={null} prop in the JSX output, which silently drops all emotion styles at runtime. The if css: guard only checks the pre-conversion dict, not the output. A None check on the result would make this safe.

return rest

Expand Down Expand Up @@ -1047,11 +1078,14 @@ def _normalize_component_return(value: Any) -> Component | None:
return None


def _lift_rest_props(component: Component) -> Component:
def _lift_rest_props(component: Component, rest_target_fields: set[str]) -> Component:
"""Convert RestProp children into special props.

Args:
component: The component tree to rewrite.
rest_target_fields: Accumulator that gathers the declared field names of
every component a ``RestProp`` is spread onto, so the call site can
tell forwarded props apart from CSS props.

Returns:
The rewritten component tree.
Expand All @@ -1064,10 +1098,11 @@ def _lift_rest_props(component: Component) -> Component:
for child in component.children:
if isinstance(child, Bare) and isinstance(child.contents, RestProp):
special_props.append(child.contents)
rest_target_fields.update(component.get_fields())
continue

if isinstance(child, Component):
child = _lift_rest_props(child)
child = _lift_rest_props(child, rest_target_fields)

rewritten_children.append(child)

Expand Down Expand Up @@ -1267,13 +1302,17 @@ def _build_args_function(


def _evaluate_component_body(
fn: Callable[..., Any], params: tuple[MemoParam, ...]
fn: Callable[..., Any],
params: tuple[MemoParam, ...],
rest_target_fields: set[str],
) -> Component:
"""Run a component memo's body and return its compiled component.

Args:
fn: The decorated function.
params: The analyzed memo parameters.
rest_target_fields: Accumulator populated with the field names of the
component(s) the body spreads an ``rx.RestProp`` onto.

Returns:
The wrapped component the body returned.
Expand All @@ -1288,7 +1327,7 @@ def _evaluate_component_body(
"`rx.Component` or `rx.Var[rx.Component]`."
)
raise TypeError(msg)
return _lift_rest_props(body)
return _lift_rest_props(body, rest_target_fields)


def _evaluate_function_body(
Expand Down Expand Up @@ -1325,12 +1364,16 @@ def _create_component_definition(
TypeError: If the function does not return a component.
"""
params = _analyze_params(fn, for_component=True)
rest_target_fields: set[str] = set()
return MemoComponentDefinition(
fn=fn,
python_name=fn.__name__,
params=params,
export_name=format.to_title_case(fn.__name__),
_component=_LazyBody.ready(_evaluate_component_body(fn, params)),
_component=_LazyBody.ready(
_evaluate_component_body(fn, params, rest_target_fields)
),
_rest_target_fields=rest_target_fields,
)


Expand Down Expand Up @@ -1881,15 +1924,17 @@ def memo(fn: Callable[..., Any]) -> _MemoComponentWrapper | _MemoFunctionWrapper
# where the name resolves to ``wrapper`` (already bound by first use).
definition: MemoComponentDefinition | MemoFunctionDefinition
if is_component:
rest_target_fields: set[str] = set()
definition = MemoComponentDefinition(
fn=fn,
python_name=fn.__name__,
params=params,
export_name=format.to_title_case(fn.__name__),
_component=_LazyBody(
lambda: _evaluate_component_body(fn, params),
lambda: _evaluate_component_body(fn, params, rest_target_fields),
placeholder=Fragment.create(),
),
_rest_target_fields=rest_target_fields,
)
wrapper = _create_component_wrapper(definition)
else:
Expand Down
Loading
Loading