Skip to content

Auto-memoized stateful components drop props/refs injected by Radix asChild Slot (form field submits empty form_data) #6849

Description

@masenf

Describe the bug

When a component's props reference a state Var, the compiler's auto-memoization pass extracts it into a passthrough memo wrapper of the form:

export const Textfieldroot_textfield__root_<hash> = memo(({children}) => {
    const login_state = useContext(StateContexts....login_state)
    return jsx(RadixThemesTextField.Root, {defaultValue: ..., placeholder: ..., required: true, type: "text"})
});

That wrapper destructures only children and is not wrapped in forwardRef. Any prop (or ref) a parent injects onto it is silently discarded.

This breaks every Radix asChild / Slot parent, because Slot works by cloning its child and injecting props onto it. The concrete user-visible symptom is with rx.form.control(..., as_child=True): Radix injects name, id, aria-describedby, onInvalid and a ref onto the child, all of it is dropped, and the rendered <input> ends up with no name attribute at all — so new FormData(form) returns {} and the field is missing from the on_submit payload.

The missing id is the tell that this is not a name-specific bug: none of Slot's props arrive.

This was reported by a user as "putting name on rx.form.field instead of rx.input drops the value from form_data". That framing is a red herring — the documented pattern (name on rx.form.field only) works fine as long as no prop on the input references state.

Specify the system information

  • reflex 0.9.8 (reproduced on main @ 69ef304)
  • @radix-ui/themes 3.3.0, @radix-ui/react-form 0.1.14
  • Python 3.14, macOS (darwin 25.5.0)
  • Reproduced in --env prod; not dialog-, browser-, or mode-specific.

Steps to reproduce

Two forms identical except for whether default_value is a state Var:

import reflex as rx


class MinimalState(rx.State):
    user_name: str = "Ada"
    got: dict = {}

    @rx.event
    def handle(self, form_data: dict):
        self.got = form_data


def _form(label, input_component):
    return rx.vstack(
        rx.heading(label, size="3"),
        rx.form.root(
            rx.form.field(
                rx.form.control(input_component, as_child=True),
                rx.form.submit(rx.button("Submit"), as_child=True),
                name="full_name",
            ),
            on_submit=MinimalState.handle,
        ),
    )


def index():
    return rx.container(
        _form("BROKEN", rx.input(default_value=MinimalState.user_name)),
        _form("OK", rx.input(default_value="Ada")),
        rx.code(MinimalState.got.to_string()),
    )


app = rx.App()
app.add_page(index)

Submit each form and inspect the rendered input:

default_value name attr id attr new FormData(form)
MinimalState.user_name (state Var) None None {}
"Ada" (literal) full_name radix-_R_1aeiqj5_ {"full_name":"Ada"}

Rendered HTML in the broken case — note no name, no id:

<input spellcheck="false" placeholder="Enter your full name" required=""
       class="rt-reset rt-TextFieldInput" type="text" value="Ada Lovelace">

Any state-dependent prop triggers it, not just default_value.

Expected behavior

The memo wrapper should be transparent to its parent: props injected by a Slot parent should reach the wrapped component, and refs should reach the underlying DOM node. The docs' low-level form example should keep working when the input binds to state.

Relevant implementation

  • reflex/compiler/plugins/memoize.pyMemoizeStatefulPlugin, the pass that decides what to wrap (_should_memoize).
  • packages/reflex-base/src/reflex_base/components/memo.pycreate_passthrough_component_memo(), which builds the ({children}) => ... passthrough shown above.

Possible directions (not mutually exclusive):

  1. Make passthrough wrappers prop-transparent: generate forwardRef((props, ref) => ...) and spread ...props onto the wrapped root so Slot-injected props and refs pass through. This fixes as_child generally, not just forms. Needs care so the wrapper's own computed props still win/merge sensibly and so children handling is unchanged.
  2. Suppress memoization under a Slot parent: have rx.form.control(as_child=True) (and other as_child users) mark their child MemoizationDisposition.NEVER so the child renders inline and Slot can clone it directly. Narrower, but as_child is used in many places and each would need opting in.

Option 1 seems the more complete fix, since this affects any asChild parent — rx.form.control, rx.form.submit, rx.dialog.trigger, etc. — whenever the child is stateful.

Workaround for users

Set the attribute explicitly on the component instead of relying on Slot injection, since props the wrapper renders itself do survive:

rx.form.control(
    rx.input(default_value=LoginState.user_name, name="full_name"),
    as_child=True,
)

Additional context

A separate, unrelated way to get an empty payload (user error rather than a bug, but a common one): placing rx.input directly under rx.form.field without a rx.form.control wrapper. Nothing forwards the field's name and the input renders unnamed. Worth a docs note, since name on rx.form.field is Radix validation wiring rather than an HTML attribute on the input.

Also noted while investigating: rx.form.control(rx.input(...)) without as_child=True is a hard React render error (input is a self-closing tag and must neither have children nor use dangerouslySetInnerHTML) rather than a graceful failure.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions