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
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 @@ -1855,15 +1898,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
2 changes: 1 addition & 1 deletion pyi_hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,5 +120,5 @@
"packages/reflex-components-sonner/src/reflex_components_sonner/toast.pyi": "2c5fadcc014056f041cd4d916137d9e7",
"reflex/__init__.pyi": "674cc55e646deb97c0e414e1d0e850ef",
"reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e",
"reflex/experimental/memo.pyi": "97f96db9b67acdd103f71f9da3548600"
"reflex/experimental/memo.pyi": "3d05914f9ca8c966258a15672461caeb"
}
3 changes: 3 additions & 0 deletions tests/integration/test_memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ def index() -> rx.Component:
value=formatted_price,
id="summary-card",
class_name="forwarded-summary-card",
font_weight="bold",
),
)

Expand Down Expand Up @@ -120,6 +121,8 @@ def test_memo_app(memo_app: AppHarness):

summary_card = driver.find_element(By.ID, "summary-card")
assert "forwarded-summary-card" in (summary_card.get_attribute("class") or "")
# CSS props forwarded through `rx.RestProp` are applied as styles (ENG-9676).
assert summary_card.value_of_css_property("font-weight") == "700"
assert driver.find_element(By.ID, "summary-title").text == "Current Price"
assert (
driver.find_element(By.ID, "summary-child").text
Expand Down
59 changes: 52 additions & 7 deletions tests/units/components/test_memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,17 @@ def my_card(

assert isinstance(component, MemoComponent)
assert len(component.children) == 2
assert component.get_props() == ("title", "foo")
# `foo` is not a field of the rest target (`Box`), so it is routed into
# `css` just like `rx.box(foo="extra")` would; `className` is a base field.
assert component.get_props() == ("title", "css")
assert type(component) is type(component_again)
assert type(component).tag == "MyCard"
assert type(component).get_fields()["tag"].default == "MyCard"

rendered = component.render()
assert rendered["name"] == "MyCard"
assert 'title:"Hello"' in rendered["props"]
assert 'foo:"extra"' in rendered["props"]
assert 'css:({ ["foo"] : "extra" })' in rendered["props"]
assert 'className:"extra"' in rendered["props"]

definition = MEMOS["MyCard"]
Expand Down Expand Up @@ -441,6 +443,45 @@ def rest_card(rest: rx.RestProp, *, title: rx.Var[str]) -> rx.Component:
assert "{...rest}" in code


def test_memo_css_props_forwarded_via_rest_prop_become_css():
"""CSS props forwarded through an ``rx.RestProp`` compile to ``css`` (ENG-9676).

A normal component folds any kwarg that is not a declared field of the target
into emotion ``css``. A memo forwarding via ``rx.RestProp`` must do the same:
``font_weight`` is not a ``Text`` field, so it has to reach the root as ``css``
rather than a raw ``fontWeight`` plain prop the target silently drops.
"""

@rx.memo
def styled_text(rest: rx.RestProp) -> rx.Component:
return rx.text("Foo", rest)

rendered = str(styled_text(font_weight="bold", class_name="c"))
# Matches the shape of `rx.text("Bar", font_weight="bold")`.
assert "css:" in rendered
assert '["fontWeight"] : "bold"' in rendered
# Not forwarded as a plain prop the target would ignore.
assert 'fontWeight:"bold"' not in rendered
# A genuine base `Component` field stays a normal plain prop.
assert 'className:"c"' in rendered


def test_memo_rest_prop_keeps_real_target_props_as_props():
"""A prop that IS a declared field of the rest target stays a plain prop (ENG-9676).

Guards the shadowing case: ``weight`` is a real ``Text`` prop, so it must be
forwarded normally and never reclassified into ``css``.
"""

@rx.memo
def styled_text(rest: rx.RestProp) -> rx.Component:
return rx.text("Foo", rest)

rendered = str(styled_text(weight="bold"))
assert 'weight:"bold"' in rendered
assert "css:" not in rendered


def test_memo_component_still_rejects_unknown_props_without_rest():
"""Props that are not base ``Component`` fields still raise without a ``RestProp``."""

Expand Down Expand Up @@ -1364,11 +1405,15 @@ def test_bind_children_and_rest_are_noops_at_the_param_level():
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"}
def test_take_rest_forwards_target_fields_and_routes_remainder_to_css():
"""take_rest forwards declared target fields and sweeps the rest into ``css``.

Keys that are fields of the rest target are forwarded as camelCased plain
props; everything else is a CSS prop, collected under a single ``css`` key.
"""
binding = _MemoCallBinding({"foo_bar": "x", "font_weight": "bold"})
rest = binding.take_rest(component_fields={}, rest_target_fields={"foo_bar"})
assert set(rest) == {"fooBar", "css"}
assert binding.raw_kwargs == {}


Expand Down
Loading