Skip to content

Commit 12a8a8a

Browse files
harsh21234imasenf
andauthored
fix(forms): omit non-control ids from submissions (#7227)
* fix(forms): omit non-control ids from submissions * fix(forms): preserve control refs while filtering nulls * fix(forms): exclude non-control ids from refs * fix(forms): exclude non-control ids from submissions * fix(forms): preserve control ids when filtering submissions * docs(forms): document the DOM id mapping argument * test(forms): satisfy typing for private form metadata helper * test(forms): keep private helper checks type-safe * fix(forms): preserve name-only controls in submissions * fix(forms): keep ref filtering limited to static ids * fix(forms): retain named controls in ref payloads * fix(forms): fall back to normalized field identifiers * fix(forms): preserve composite control ids * style(forms): apply ruff formatting * fix(forms): retain mapped composite controls * fix(forms): filter unresolved ref values * fix(forms): preserve empty ref values * docs(changelog): note empty form values * test(forms): cover empty named fields * fix(forms): exclude non-control refs from submissions * test(forms): rely on integration coverage for memo refs * test(forms): name regression test identifiers * test(forms): pass fixture IDs into AppHarness apps * fix(forms): preserve control refs through memoization * fix(forms): reuse existing form control marker --------- Co-authored-by: Masen Furer <m_github@0x26.net>
1 parent d82c422 commit 12a8a8a

14 files changed

Lines changed: 263 additions & 10 deletions

File tree

‎docs/library/forms/form.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ def form_example():
164164
When using the `name` attribute in form controls like `rx.switch`, `rx.radio_group`, and `rx.checkbox`, these controls will only be included in the form data if their values are set (e.g., if the checkbox is checked, the switch is toggled, or a radio option is selected).
165165

166166
If you need these controls to be passed in the form data even when their values are not set, you can use the `id` attribute instead of name. The id attribute ensures that the control is always included in the submitted form data, regardless of whether its value is set or not.
167+
168+
Custom native `input`, `select`, and `textarea` components with an `id` are included automatically. For other custom form controls, set `_is_form_control = True` on the component class to include its ID-based value in submissions.
167169
```
168170

169171
```md video https://youtube.com/embed/ITOZkzjtjUA?start=5287&end=6040

‎news/7227.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Preserve ID-based form controls through automatic memoization while excluding IDs on non-controls from submissions.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Form submissions retain ID-backed controls with unset values while omitting IDs from non-controls.

‎packages/reflex-base/src/reflex_base/.templates/web/utils/state.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1602,7 +1602,7 @@ export const mergeSlotProps = (injectedProps, ownProps, refProp) => {
16021602
*/
16031603
export const getRefValue = (ref) => {
16041604
if (!ref || !ref.current) {
1605-
return;
1605+
return null;
16061606
}
16071607
if (ref.current.type == "checkbox") {
16081608
return ref.current.checked; // chakra
@@ -1617,10 +1617,10 @@ export const getRefValue = (ref) => {
16171617
} else {
16181618
//querySelector(":checked") is needed to get value from radio_group
16191619
return (
1620-
ref.current.value ||
1620+
ref.current.value ??
16211621
(ref.current.querySelector &&
1622-
ref.current.querySelector(":checked") &&
1623-
ref.current.querySelector(":checked")?.value)
1622+
ref.current.querySelector(":checked")?.value) ??
1623+
null
16241624
);
16251625
}
16261626
};

‎packages/reflex-base/src/reflex_base/components/memo.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,6 +2169,7 @@ def passthrough(children: Var[Component]) -> Component:
21692169
# reachable on the page-level wrapper via the plugin's
21702170
# ``_get_all_refs`` delegation back to the source component.
21712171
new_component.children = [hole_bare]
2172+
object.__setattr__(new_component, "_form_control_source", component)
21722173
# Compile-time walkers that need the real subtree (notably
21732174
# ``Form._get_form_refs`` collecting id-based input refs into the
21742175
# generated ``handleSubmit`` JS) call ``self._get_all_refs()`` while
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Exclude IDs on forms and other non-controls from form submissions while preserving unset ID-backed controls and supporting custom controls marked with `_is_form_control`.

‎packages/reflex-components-core/src/reflex_components_core/core/debounce.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class DebounceInput(Component):
2525
library = "react-debounce-input@3.3.0"
2626
tag = "DebounceInput"
2727
is_default = True
28+
_is_form_control = True
2829

2930
# DebounceInput is a class component: a plain ``ref`` resolves to the
3031
# instance (``_render`` strips it), and the real ``<input>`` is exposed

‎packages/reflex-components-core/src/reflex_components_core/el/elements/forms.py‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from .base import BaseHTML, RawTextBaseHTML, VoidBaseHTML
4040

4141
_DYNAMIC_FORM_FIELD = object()
42+
_NATIVE_FORM_CONTROL_TAGS = frozenset({"input", "select", "textarea"})
4243

4344

4445
def _handle_submit_js_template(
@@ -64,7 +65,10 @@ def _handle_submit_js_template(
6465
const handleSubmit_{handle_submit_unique_name} = useCallback((ev) => {{
6566
const $form = ev.target
6667
ev.preventDefault()
67-
const {form_data} = {{...Object.fromEntries(new FormData($form).entries()), ...{field_ref_mapping}}};
68+
const {form_data} = {{
69+
...Object.fromEntries(new FormData($form).entries()),
70+
...{field_ref_mapping}
71+
}};
6872
6973
({on_submit_event_chain}(ev));
7074
@@ -98,6 +102,10 @@ def _iter_form_components(component: BaseComponent) -> Iterator[BaseComponent]:
98102
Yields:
99103
The component and its nested component descendants.
100104
"""
105+
form_control_source = getattr(component, "_form_control_source", None)
106+
if isinstance(form_control_source, BaseComponent):
107+
yield from _iter_form_components(form_control_source)
108+
return
101109
yield component
102110
for child in component.children:
103111
if isinstance(child, BaseComponent):
@@ -135,6 +143,42 @@ def _get_static_string_prop(
135143
return None
136144

137145

146+
def _is_form_control_component(component: BaseComponent) -> bool:
147+
"""Return whether a component or its memoized type is a form control.
148+
149+
Custom component classes can opt in with ``_is_form_control = True``.
150+
151+
Args:
152+
component: The component to inspect.
153+
154+
Returns:
155+
Whether the component contributes a form field.
156+
"""
157+
if getattr(component, "_is_form_control", False):
158+
return True
159+
wrapped_component_type = getattr(component, "_wrapped_component_type", None)
160+
if getattr(wrapped_component_type, "_is_form_control", False):
161+
return True
162+
return getattr(component, "tag", None) in _NATIVE_FORM_CONTROL_TAGS
163+
164+
165+
def _get_form_control_refs(component: BaseComponent) -> set[str]:
166+
"""Collect refs belonging to form controls in a component subtree.
167+
168+
Args:
169+
component: The component tree to inspect.
170+
171+
Returns:
172+
The refs owned by form controls.
173+
"""
174+
return {
175+
ref
176+
for child in _iter_form_components(component)
177+
if isinstance(child, Component) and _is_form_control_component(child)
178+
if (ref := child.get_ref()) is not None
179+
}
180+
181+
138182
def _get_required_typed_dict_fields(typed_dict_type: type[Any]) -> frozenset[str]:
139183
"""Resolve required TypedDict keys across Python versions.
140184
@@ -359,9 +403,11 @@ def _render(self) -> CommonTag:
359403
return render_tag
360404

361405
def _get_form_refs(self) -> dict[str, Any]:
362-
# Send all the input refs to the handler.
406+
form_control_refs = _get_form_control_refs(self)
363407
form_refs = {}
364408
for ref in self._get_all_refs():
409+
if ref not in form_control_refs:
410+
continue
365411
# when ref start with refs_ it's an array of refs, so we need different method
366412
# to collect data
367413
if ref.startswith("refs_"):
@@ -388,7 +434,7 @@ def _get_static_form_field_keys(self) -> tuple[set[str], bool]:
388434
has_dynamic_identifiers = False
389435

390436
for component in _iter_form_components(self):
391-
if component is self or not getattr(component, "_is_form_control", False):
437+
if component is self or not _is_form_control_component(component):
392438
continue
393439

394440
name = _get_static_string_prop(component, "name")

‎pyi_hashes.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
"packages/reflex-components-core/src/reflex_components_core/el/element.pyi": "930b020d7c933ab0e29ba7971ba1abd2",
2828
"packages/reflex-components-core/src/reflex_components_core/el/elements/__init__.pyi": "ded6fc0acc37c9260da5cfd5f4309079",
2929
"packages/reflex-components-core/src/reflex_components_core/el/elements/base.pyi": "d075a1420b5a08cccd45aa58599ab3dc",
30-
"packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "42b0e8e04b79d7aa78d16b4f9ad39cef",
30+
"packages/reflex-components-core/src/reflex_components_core/el/elements/forms.pyi": "c0ccd355f1e31a799069a1fb2207dac2",
3131
"packages/reflex-components-core/src/reflex_components_core/el/elements/inline.pyi": "c4085b9ee43847b6765cbf775341bc0b",
3232
"packages/reflex-components-core/src/reflex_components_core/el/elements/media.pyi": "c2515454828415b78799f0ee0fb7a6af",
3333
"packages/reflex-components-core/src/reflex_components_core/el/elements/metadata.pyi": "2d8d722c68f5a3082c0d3fa4883bb5c0",

‎reflex/compiler/plugins/memoize.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ def _build_wrapper(
403403
# wrapped subtree. Delegate ref collection to the original component
404404
# so descendants inside the memo body remain reachable for ref lookup.
405405
object.__setattr__(wrapper, "_get_all_refs", comp._get_all_refs)
406+
# Keep each ref's owning control available after replacing the subtree.
407+
object.__setattr__(wrapper, "_form_control_source", comp)
406408
return wrapper
407409

408410

0 commit comments

Comments
 (0)