Skip to content

Commit 7cbd46b

Browse files
author
User
committed
Fix #270: preserve multi-select form values across submission
The `django_form` component lost multi-select (MultipleChoiceField) values after a successful submission because two separate issues prevented the re-rendered form from restoring the selection on the client: 1. The `onSubmit` ReactPy event handler on the `<form>` element was redundant with the client-side `DjangoForm` submit listener (which already calls `preventDefault()` and forwards the submitted data). When a form was submitted a second time (after re-rendering validation errors), this extra handler caused the WebSocket to close and remount the component, dropping the submission before it reached the server. Removing the redundant `onSubmit` prop lets the client-side listener reliably deliver the data. 2. ReactPy's built-in `select_element_to_reactjs` transform strips the `selected` attribute from `<option>` elements and stores it as `defaultValue` on the parent `<select>`. Preact does not apply a `<select>`'s `defaultValue` prop to its `<option>` children, so the selection was lost on re-render. `set_value_prop_on_select_element` now also re-applies `selected` on the matching `<option>` elements, which Preact applies as the option's DOM `selected` property. Also fixes the multi-select assertions in `test_form_basic` to read the selected `<option>` values, since `input_value()` returns only the first selection on a multi-select.
1 parent 86ca94f commit 7cbd46b

3 files changed

Lines changed: 46 additions & 35 deletions

File tree

‎src/reactpy_django/forms/components.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from django.forms import Form, ModelForm
1010
from reactpy import component, hooks, html, utils
11-
from reactpy.core.events import event
1211
from reactpy.reactjs import component_from_file
1312

1413
from reactpy_django.forms.transforms import (
@@ -126,11 +125,9 @@ async def _on_change(_event):
126125
if not rendered_form:
127126
return None
128127

129-
form_props = {
128+
form_props: dict[str, Any] = {
130129
"id": f"reactpy-{uuid}",
131130
"key": f"reactpy-{uuid}-{render_count}",
132-
# Intercept the form submission to prevent the browser from navigating
133-
"onSubmit": event(lambda _: None, prevent_default=True),
134131
}
135132
if on_change:
136133
form_props["onChange"] = _on_change

‎src/reactpy_django/forms/transforms.py‎

Lines changed: 37 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,32 @@ def convert_textarea_children_to_prop(vdom_tree: VdomDict) -> VdomDict:
3333

3434

3535
def set_value_prop_on_select_element(vdom_tree: VdomDict) -> VdomDict:
36-
"""Use the `value` prop on <select> instead of setting `selected` on <option>."""
37-
# If the current tag is <select>, remove 'selected' prop from any <option> children and
38-
# instead set the 'value' prop on the <select> tag.
36+
"""Set the correct selection props on a <select> and its <option> children.
37+
38+
ReactPy's built-in ``RequiredTransforms.select_element_to_reactjs`` (which runs during
39+
``string_to_reactpy``, before this transform) removes the ``selected`` attribute from
40+
each ``<option>`` and records the selected value(s) as ``defaultValue`` on the parent
41+
``<select>`` element. That works with React, but Preact does **not** apply a ``<select>``
42+
element's ``defaultValue`` prop to its ``<option>`` children. As a result the selection is
43+
lost when a form remounts/``<option>`` nodes are re-created (e.g. after a submission).
44+
45+
To preserve selection for Preact, this transform reads the ``defaultValue`` already set on
46+
the ``<select>`` and sets the ``selected`` prop directly on each matching ``<option>``
47+
element, which Preact applies as the option's DOM ``selected`` property.
48+
"""
3949
if vdom_tree["tagName"] == "select" and "children" in vdom_tree:
4050
vdom_tree.setdefault("attributes", {})
41-
selected_options = _find_selected_options(vdom_tree)
42-
multiple_choice = vdom_tree["attributes"]["multiple"] = bool(vdom_tree["attributes"].get("multiple"))
43-
if selected_options and not multiple_choice:
44-
vdom_tree["attributes"]["defaultValue"] = selected_options[0]
45-
if selected_options and multiple_choice:
46-
vdom_tree["attributes"]["defaultValue"] = selected_options
51+
attributes = vdom_tree["attributes"]
52+
attributes["multiple"] = bool(attributes.get("multiple"))
53+
54+
# Preact ignores a <select>'s `defaultValue`, so propagate the selected value(s)
55+
# (already stored on the <select> by reactpy's builtin transform) onto the
56+
# matching <option> elements as `selected`.
57+
selected_values = attributes.get("defaultValue")
58+
if isinstance(selected_values, str):
59+
selected_values = [selected_values]
60+
if selected_values:
61+
_set_selected_on_options(vdom_tree, set(selected_values))
4762

4863
return vdom_tree
4964

@@ -87,31 +102,25 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict:
87102
return vdom_tree
88103

89104

90-
def _find_selected_options(vdom_node: Any) -> list[str]:
91-
"""Recursively iterate through the tree to find all <option> tags with the 'selected' prop.
92-
Returns a list of the 'value' prop of each selected <option>.
105+
def _set_selected_on_options(vdom_node: Any, selected_values: set[str]) -> None:
106+
"""Recursively mark the matching <option> elements as selected.
93107
94-
.. note::
95-
We intentionally do **not** remove the ``selected`` prop from the ``<option>`` elements.
96-
The ``defaultValue`` attribute is already set on the ``<select>`` element for initial
97-
mount in React/Preact, but it is only applied once (on mount). Keeping ``selected``
98-
on the ``<option>`` elements ensures that selection state is correctly restored after
99-
the form is re-rendered (e.g. after a form submission that does not trigger a full
100-
Preact remount)."""
108+
We set the ``selected`` prop on the <option> element. Preact applies ``selected``
109+
as a DOM property on ``HTMLOptionElement``, which updates the option's ``selected``
110+
state on every re-render. This is required because Preact does not apply a
111+
<select>'s ``defaultValue`` prop to its <option> children, so selection is otherwise
112+
lost when a form remounts (e.g. on submit).
113+
"""
101114
if not isinstance(vdom_node, dict):
102-
return []
115+
return
103116

104-
selected_options = []
105117
if vdom_node["tagName"] == "option" and "attributes" in vdom_node:
106-
value = vdom_node["attributes"].setdefault("value", vdom_node["children"][0])
107-
108-
if "selected" in vdom_node["attributes"]:
109-
selected_options.append(value)
118+
value = vdom_node["attributes"].get("value")
119+
if value in selected_values:
120+
vdom_node["attributes"]["selected"] = True
110121

111122
for child in vdom_node.get("children", []):
112-
selected_options.extend(_find_selected_options(child))
113-
114-
return selected_options
123+
_set_selected_on_options(child, selected_values)
115124

116125

117126
def _normalize_prop_name(prop_name: str) -> str:

‎tests/test_app/tests/test_components.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -812,11 +812,16 @@ def test_form_basic(self):
812812
# Verify multi-select field values survived the round-trip
813813
# After successful submission, the re-rendered form should have
814814
# the same options selected, proving the FormData duplicate-key fix worked.
815-
assert self.page.locator("#id_multiple_choice_field").input_value() == ["2", "3"]
816-
assert self.page.locator("#id_typed_multiple_choice_field").input_value() == ["1", "2"]
815+
# NOTE: `input_value()` on a multi-select returns only the first selected
816+
# value as a string, so we read the selected <option> values directly.
817+
def _selected_values(selector: str) -> list[str]:
818+
return self.page.locator(f"{selector} option:checked").evaluate_all("els => els.map(e => e.value)")
819+
820+
assert sorted(_selected_values("#id_multiple_choice_field")) == ["2", "3"]
821+
assert sorted(_selected_values("#id_typed_multiple_choice_field")) == ["1", "2"]
817822

818823
# Verify model multi-select field values survived the round-trip
819-
model_choice_selected = self.page.locator("#id_model_multiple_choice_field").input_value()
824+
model_choice_selected = _selected_values("#id_model_multiple_choice_field")
820825
assert sorted(model_choice_selected) == sorted([
821826
model_choice_field_values[1],
822827
model_choice_field_values[2],

0 commit comments

Comments
 (0)