Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
27d6de6
fix(forms): omit non-control ids from submissions
harsh21234i Sep 21, 2026
6bc7f72
fix(forms): preserve control refs while filtering nulls
harsh21234i Sep 21, 2026
3ceee0a
Merge branch 'main' into harsh21234i-fix-form-null-payload
harsh21234i Sep 21, 2026
39119d1
fix(forms): exclude non-control ids from refs
harsh21234i Sep 21, 2026
1033b9d
Merge remote-tracking branch 'origin/harsh21234i-fix-form-null-payloa…
harsh21234i Sep 21, 2026
7b95402
fix(forms): exclude non-control ids from submissions
harsh21234i Sep 21, 2026
c76ece1
fix(forms): preserve control ids when filtering submissions
harsh21234i Sep 26, 2026
14bd683
docs(forms): document the DOM id mapping argument
harsh21234i Sep 26, 2026
9ed9b33
test(forms): satisfy typing for private form metadata helper
harsh21234i Sep 26, 2026
4d08fda
test(forms): keep private helper checks type-safe
harsh21234i Sep 26, 2026
f967a2d
fix(forms): preserve name-only controls in submissions
harsh21234i Sep 26, 2026
56b6629
fix(forms): keep ref filtering limited to static ids
harsh21234i Sep 26, 2026
b250960
fix(forms): retain named controls in ref payloads
harsh21234i Sep 26, 2026
3092444
fix(forms): fall back to normalized field identifiers
harsh21234i Sep 26, 2026
abc46be
fix(forms): preserve composite control ids
harsh21234i Sep 26, 2026
2b8a1e1
style(forms): apply ruff formatting
harsh21234i Sep 26, 2026
92904a2
fix(forms): retain mapped composite controls
harsh21234i Sep 26, 2026
a752674
fix(forms): filter unresolved ref values
harsh21234i Sep 26, 2026
8b3d17d
fix(forms): preserve empty ref values
harsh21234i Sep 26, 2026
4d36ef3
docs(changelog): note empty form values
harsh21234i Sep 26, 2026
d11f287
test(forms): cover empty named fields
harsh21234i Sep 26, 2026
b8e274e
Merge branch 'main' into harsh21234i-fix-form-null-payload
masenf Sep 29, 2026
9eea606
Merge branch 'main' into harsh21234i-fix-form-null-payload
harsh21234i Sep 29, 2026
561242d
fix(forms): exclude non-control refs from submissions
harsh21234i Sep 29, 2026
9438db8
test(forms): rely on integration coverage for memo refs
harsh21234i Sep 29, 2026
fe02e4b
test(forms): name regression test identifiers
harsh21234i Sep 29, 2026
da3fc26
test(forms): pass fixture IDs into AppHarness apps
harsh21234i Sep 29, 2026
b6c3afe
fix(forms): preserve control refs through memoization
harsh21234i Oct 1, 2026
8576664
fix(forms): reuse existing form control marker
harsh21234i Oct 1, 2026
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/7227.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Form submissions preserve empty ID-backed values and their control refs across memoization, while omitting IDs from non-controls.
Original file line number Diff line number Diff line change
Expand Up @@ -1508,9 +1508,8 @@ export const getRefValue = (ref) => {
} else {
//querySelector(":checked") is needed to get value from radio_group
return (
ref.current.value ||
ref.current.value ??
(ref.current.querySelector &&
ref.current.querySelector(":checked") &&
ref.current.querySelector(":checked")?.value)
);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/reflex-base/src/reflex_base/components/memo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2082,6 +2082,13 @@ def passthrough(children: Var[Component]) -> Component:
# empty ``field_ref_mapping``. Delegate ref collection back to the
# source component so descendants behind the hole remain visible.
object.__setattr__(new_component, "_get_all_refs", component._get_all_refs)
# Form submit handlers also need the original subtree to identify which
# of those refs belong to controls rather than structural ancestors.
get_form_control_refs = getattr(component, "_get_form_control_refs", None)
Comment thread
masenf marked this conversation as resolved.
Outdated
if get_form_control_refs is not None:
object.__setattr__(
new_component, "_get_form_control_refs", get_form_control_refs
)
return new_component

# Evaluate once to compute the tag from the rendered memo body shape.
Expand Down
1 change: 1 addition & 0 deletions packages/reflex-components-core/news/7227.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Exclude IDs on forms and other non-controls from form submissions while preserving empty values and ID-backed controls.
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class DebounceInput(Component):
library = "react-debounce-input@3.3.0"
tag = "DebounceInput"
is_default = True
_is_form_control = True

# DebounceInput is a class component: a plain ``ref`` resolves to the
# instance (``_render`` strips it), and the real ``<input>`` is exposed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ def _handle_submit_js_template(
const handleSubmit_{handle_submit_unique_name} = useCallback((ev) => {{
const $form = ev.target
ev.preventDefault()
const {form_data} = {{...Object.fromEntries(new FormData($form).entries()), ...{field_ref_mapping}}};
const {form_data} = {{
...Object.fromEntries(new FormData($form).entries()),
...Object.fromEntries(
Object.entries({field_ref_mapping}).filter(([, value]) => value != null)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
masenf marked this conversation as resolved.
Outdated
)
}};

({on_submit_event_chain}(ev));

Expand Down Expand Up @@ -135,6 +140,38 @@ def _get_static_string_prop(
return None


def _is_form_control_component(component: BaseComponent) -> bool:
Comment thread
masenf marked this conversation as resolved.
"""Return whether a component or its memoized type is a form control.

Args:
component: The component to inspect.

Returns:
Whether the component contributes a form field.
"""
if getattr(component, "_is_form_control", False):
return True
wrapped_component_type = getattr(component, "_wrapped_component_type", None)
return getattr(wrapped_component_type, "_is_form_control", False)


def _get_form_control_refs(component: BaseComponent) -> set[str]:
"""Collect refs belonging to form controls in a component subtree.

Args:
component: The component tree to inspect.

Returns:
The refs owned by form controls.
"""
return {
ref
for child in _iter_form_components(component)
if isinstance(child, Component) and _is_form_control_component(child)
if (ref := child.get_ref()) is not None
}


def _get_required_typed_dict_fields(typed_dict_type: type[Any]) -> frozenset[str]:
"""Resolve required TypedDict keys across Python versions.

Expand Down Expand Up @@ -316,6 +353,18 @@ def create(cls, *children, **props):
).hexdigest()
return form

def _get_form_control_refs(self) -> set[str]:
"""Get refs for form controls in this form's component subtree.

Returns:
The refs owned by form controls.
"""
refs = getattr(self, "_form_control_refs", None)
Comment thread
masenf marked this conversation as resolved.
Outdated
if refs is None:
refs = _get_form_control_refs(self)
object.__setattr__(self, "_form_control_refs", refs)
return refs

def add_imports(self) -> ImportDict:
"""Add imports needed by the form component.

Expand Down Expand Up @@ -359,9 +408,11 @@ def _render(self) -> CommonTag:
return render_tag

def _get_form_refs(self) -> dict[str, Any]:
# Send all the input refs to the handler.
form_control_refs = self._get_form_control_refs()
form_refs = {}
for ref in self._get_all_refs():
for ref in dict.fromkeys(self._get_all_refs()):
if ref not in form_control_refs:
continue
# when ref start with refs_ it's an array of refs, so we need different method
# to collect data
if ref.startswith("refs_"):
Expand All @@ -388,7 +439,7 @@ def _get_static_form_field_keys(self) -> tuple[set[str], bool]:
has_dynamic_identifiers = False

for component in _iter_form_components(self):
if component is self or not getattr(component, "_is_form_control", False):
if component is self or not _is_form_control_component(component):
continue

name = _get_static_string_prop(component, "name")
Expand Down
28 changes: 26 additions & 2 deletions tests/integration/test_form_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@

from reflex.testing import AppHarness

FORM_CONTENT_WRAPPER_ID = "form_content_wrapper"
FORM_ID = "form_id"
Comment thread
harsh21234i marked this conversation as resolved.

def FormSubmit(form_component):

def FormSubmit(
form_component,
form_id=FORM_ID,
form_content_wrapper_id=FORM_CONTENT_WRAPPER_ID,
):
"""App with a form using on_submit.

Args:
form_component: The str name of the form component to use.
form_id: The form element ID.
form_content_wrapper_id: The non-control wrapper ID.
"""
import reflex as rx

Expand All @@ -42,6 +51,7 @@ def index():
eval(form_component)(
rx.vstack(
rx.input(id="name_input"),
rx.input(id="empty_input"),
rx.checkbox(id="bool_input"),
rx.switch(id="bool_input2"),
rx.checkbox(id="bool_input3"),
Expand All @@ -62,21 +72,29 @@ def index():
on_change=rx.console_log,
),
rx.button("Submit", type_="submit"),
id=form_content_wrapper_id,
),
on_submit=FormState.form_submit,
custom_attrs={"action": "/invalid"},
id=form_id,
),
rx.text(FormState.form_data.to_string(), id="form-data"),
rx.spacer(),
height="100vh",
)


def FormSubmitName(form_component):
def FormSubmitName(
form_component,
form_id=FORM_ID,
form_content_wrapper_id=FORM_CONTENT_WRAPPER_ID,
):
"""App with a form using on_submit.

Args:
form_component: The str name of the form component to use.
form_id: The form element ID.
form_content_wrapper_id: The non-control wrapper ID.
"""
import reflex as rx

Expand All @@ -101,6 +119,7 @@ def index():
eval(form_component)(
rx.vstack(
rx.input(name="name_input"),
rx.input(name="empty_input"),
rx.checkbox(name="bool_input"),
rx.switch(name="bool_input2"),
rx.checkbox(name="bool_input3"),
Expand All @@ -120,9 +139,11 @@ def index():
),
rx.button("Submit", type_="submit"),
rx.icon_button(rx.icon(tag="plus")),
id=form_content_wrapper_id,
),
on_submit=FormState.form_submit,
custom_attrs={"action": "/invalid"},
id=form_id,
),
rx.text(FormState.form_data.to_string(), id="form-data"),
rx.spacer(),
Expand Down Expand Up @@ -238,7 +259,10 @@ async def test_submit(driver, form_submit: AppHarness):

print(form_data)

assert FORM_ID not in form_data
assert FORM_CONTENT_WRAPPER_ID not in form_data
assert form_data["name_input"] == "foo"
assert form_data["empty_input"] == ""
Comment thread
harsh21234i marked this conversation as resolved.
Comment thread
harsh21234i marked this conversation as resolved.
assert form_data["bool_input"]
assert form_data["bool_input2"]
assert not form_data.get("bool_input3", False)
Expand Down
59 changes: 59 additions & 0 deletions tests/units/components/forms/test_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from reflex_base.event import EventChain, prevent_default
from reflex_base.utils.exceptions import EventHandlerValueError
from reflex_base.vars.base import Var
from reflex_components_core.core.debounce import DebounceInput
from reflex_components_core.el.elements.forms import (
AUTO_HEIGHT_JS,
ENTER_KEY_SUBMIT_JS,
Expand All @@ -17,6 +18,14 @@
import reflex as rx
from reflex.compiler.utils import _root_only_custom_code

EMAIL_FIELD_ID = "email"
EMAIL_LABEL_ID = "email_label"
SUBMIT_BUTTON_ID = "submit_button"
INPUT_WRAPPER_ID = "input_wrapper"
FORM_ID = "form_id"
DEBOUNCED_INPUT_ID = "debounced_input"
MEMOIZED_INPUT_ID = "memoized_input"


def test_render_on_submit():
"""Test that on_submit event chain is rendered as a separate function."""
Expand All @@ -37,6 +46,56 @@ def test_render_no_on_submit():
assert f.event_triggers["on_submit"].events[0] == prevent_default


def test_form_submit_filters_null_ref_values():
"""Only form-control refs with resolved values should be submitted."""

class FormState(rx.State):
@rx.event
def on_submit(self, form_data: dict):
pass

form = HTMLForm.create(
rx.box(
Input.create(id=EMAIL_FIELD_ID),
rx.text("Email", id=EMAIL_LABEL_ID),
rx.button("Submit", id=SUBMIT_BUTTON_ID),
id=INPUT_WRAPPER_ID,
),
on_submit=FormState.on_submit,
id=FORM_ID,
)
submit_hook = form.add_hooks()[0]
assert "filter(([, value]) => value != null)" in submit_hook
assert f"ref_{EMAIL_FIELD_ID}" in submit_hook
assert f"ref_{EMAIL_LABEL_ID}" not in submit_hook
assert f"ref_{SUBMIT_BUTTON_ID}" not in submit_hook
assert f"ref_{INPUT_WRAPPER_ID}" not in submit_hook
assert f"ref_{FORM_ID}" not in submit_hook


def test_form_refs_include_debounced_controls():
"""ID-only debounced inputs remain available to submit handlers."""
form = HTMLForm.create(
DebounceInput.create(
Input.create(id=DEBOUNCED_INPUT_ID, on_change=rx.console_log)
)
)

assert f"ref_{DEBOUNCED_INPUT_ID}" in form.add_hooks()[0]


def test_form_refs_include_memoized_controls(monkeypatch):
"""Memo wrappers retain the form-control marker of their wrapped component."""

class MemoizedInput(Input):
_is_form_control = False

monkeypatch.setattr(MemoizedInput, "_wrapped_component_type", Input, raising=False)
form = HTMLForm.create(MemoizedInput.create(id=MEMOIZED_INPUT_ID))

assert f"ref_{MEMOIZED_INPUT_ID}" in form.add_hooks()[0]


@pytest.mark.parametrize("form_factory", [HTMLForm.create, Form.create])
def test_on_submit_accepts_typed_dict_form_data(form_factory):
"""TypedDict-annotated submit handlers should be accepted."""
Expand Down
Loading