From 85d35fc04a60cf74dd489f207b5536ccbf3c92ae Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 4 Jun 2026 19:33:50 +0500 Subject: [PATCH 1/2] fix: route memo RestProp CSS props into css instead of dropping them A `rx.RestProp` only forwarded kwargs that matched a declared field of the target component; any other forwarded prop (e.g. `font_weight`) was emitted as a raw plain prop the target silently ignored. Track each RestProp target's field names during body evaluation so the call site can fold non-field props into emotion `css`, matching how a normal component handles unknown kwargs. --- .../src/reflex_base/components/memo.py | 65 ++++++++++++++++--- tests/integration/test_memo.py | 3 + tests/units/components/test_memo.py | 59 +++++++++++++++-- 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/components/memo.py b/packages/reflex-base/src/reflex_base/components/memo.py index c5011454e51..48937df4eb1 100644 --- a/packages/reflex-base/src/reflex_base/components/memo.py +++ b/packages/reflex-base/src/reflex_base/components/memo.py @@ -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 @@ -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: @@ -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.""" @@ -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()) @@ -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) ) return rest @@ -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. @@ -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) @@ -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. @@ -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( @@ -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, ) @@ -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: diff --git a/tests/integration/test_memo.py b/tests/integration/test_memo.py index 4a8524d6e2f..7dbbdd41cbb 100644 --- a/tests/integration/test_memo.py +++ b/tests/integration/test_memo.py @@ -73,6 +73,7 @@ def index() -> rx.Component: value=formatted_price, id="summary-card", class_name="forwarded-summary-card", + font_weight="bold", ), ) @@ -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 diff --git a/tests/units/components/test_memo.py b/tests/units/components/test_memo.py index 9276bf248c2..f3db4aed41f 100644 --- a/tests/units/components/test_memo.py +++ b/tests/units/components/test_memo.py @@ -99,7 +99,9 @@ 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" @@ -107,7 +109,7 @@ def my_card( 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"] @@ -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``.""" @@ -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 == {} From b89f1891fcd09cd8de8dc5891903a1243ea41816 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Thu, 4 Jun 2026 19:49:18 +0500 Subject: [PATCH 2/2] chore: add news entry and pyi hash for memo RestProp css fix Adds the 6605 bugfix changelog entry documenting that `@rx.memo` forwards undeclared RestProp kwargs into `css`, and regenerates the memo.pyi hash. --- packages/reflex-base/news/6605.bugfix.md | 1 + pyi_hashes.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 packages/reflex-base/news/6605.bugfix.md diff --git a/packages/reflex-base/news/6605.bugfix.md b/packages/reflex-base/news/6605.bugfix.md new file mode 100644 index 00000000000..fe12ed461fb --- /dev/null +++ b/packages/reflex-base/news/6605.bugfix.md @@ -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. diff --git a/pyi_hashes.json b/pyi_hashes.json index f7cde8facda..39287775187 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": "674cc55e646deb97c0e414e1d0e850ef", "reflex/components/__init__.pyi": "f39a2af77f438fa243c58c965f19d42e", - "reflex/experimental/memo.pyi": "97f96db9b67acdd103f71f9da3548600" + "reflex/experimental/memo.pyi": "3d05914f9ca8c966258a15672461caeb" }