From 75937c00f36878f7aa56c03144b543e1c4608d29 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Wed, 22 Jul 2026 17:40:24 -0700 Subject: [PATCH 1/2] feat: add `form` component and `use_form_data` hook Implements a `form` component akin to React Router's `
` component. - JS-side Form component intercepts submit events, serializes FormData into JSON, navigates via pushState, and passes form data to the server. - Python-side form() function renders a element with a corresponding JS listener. On submit, form data is stored in context and the router navigates to the action URL. - use_form_data() hook retrieves the most recently submitted form data. - Form data context (FormDataState) is wired into the router so it's available to all routed components. - Non-file fields only; file serialization deferred to future work. Closes #7 --- CHANGELOG.md | 1 + src/js/src/components.ts | 63 ++++++++++++++++- src/js/src/index.ts | 2 +- src/js/src/types.ts | 10 +++ src/reactpy_router/__init__.py | 6 +- src/reactpy_router/components.py | 45 +++++++++++- src/reactpy_router/hooks.py | 45 ++++++++++++ src/reactpy_router/routers.py | 11 ++- tests/test_router.py | 117 ++++++++++++++++++++++++++++++- 9 files changed, 292 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd903..c242350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Don't forget to remove deprecated code on each major release! - The `navigate` component now accepts integers for `to`, allowing relative navigation in the browser's history stack (e.g. `navigate(-1)` to go back, `navigate(1)` to go forward). - Support for ReactPy v2.x (beta). The initial URL is now sourced from the ReactPy executor (`use_connection().location`) instead of a JS-side `popstate` effect, removing a redundant network round-trip on first load. +- The `form` component now provides client-side form interception akin to React Router's ``. Form submissions are serialized via `FormData`, sent as query parameters via `pushState`, and the submitted data is made available through the `use_form_data()` hook. ### Changed diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 061cb9e..56bbf6c 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -1,6 +1,6 @@ import { React } from "@reactpy/client"; import { createLocationObject, pushState, replaceState } from "./utils"; -import { HistoryProps, LinkProps, NavigateProps } from "./types"; +import { HistoryProps, LinkProps, NavigateProps, FormProps } from "./types"; /** * Interface used to bind a ReactPy node to React. @@ -118,3 +118,64 @@ export function Navigate({ return null; } + +/** + * Form component that intercepts form submissions and notifies the server + * instead of performing a full page reload. + * + * This component is not the actual `` element. It is just an event + * listener for ReactPy-Router's server-side form component. + */ +export function Form({ onSubmitCallback, formClass }: FormProps): null { + React.useEffect(() => { + const handleSubmit = (event: Event) => { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement; + const formData = new FormData(form); + + // Serialize FormData to a Record + const serialized: Record = {}; + for (const [key, value] of formData.entries()) { + if (!serialized[key]) { + serialized[key] = []; + } + serialized[key].push(value.toString()); + } + + // Build the action URL with form data as query params (GET-style) + let action = form.getAttribute("action") || window.location.pathname; + const params = new URLSearchParams(); + for (const [key, values] of Object.entries(serialized)) { + for (const value of values) { + params.append(key, value); + } + } + const queryString = params.toString(); + if (queryString) { + action += (action.includes("?") ? "&" : "?") + queryString; + } + + pushState(action); + onSubmitCallback({ + form_data: serialized, + location: createLocationObject(), + }); + }; + + const forms = document.querySelectorAll(`form.${formClass}`); + if (forms.length === 0) { + console.warn(`Form component with class name ${formClass} not found.`); + } else { + forms.forEach((form) => { + form.addEventListener("submit", handleSubmit); + }); + } + + return () => { + forms.forEach((form) => { + form.removeEventListener("submit", handleSubmit); + }); + }; + }); + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 8de0626..662df29 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1 +1 @@ -export { bind, History, Link, Navigate } from "./components"; +export { bind, History, Link, Navigate, Form } from "./components"; diff --git a/src/js/src/types.ts b/src/js/src/types.ts index b060641..e097fe1 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -17,3 +17,13 @@ export interface NavigateProps { to: string | number; replace?: boolean; } + +export interface FormSubmitData { + form_data: Record; + location: ReactPyLocation; +} + +export interface FormProps { + onSubmitCallback: (data: FormSubmitData) => void; + formClass: string; +} diff --git a/src/reactpy_router/__init__.py b/src/reactpy_router/__init__.py index 5736183..7b310c7 100644 --- a/src/reactpy_router/__init__.py +++ b/src/reactpy_router/__init__.py @@ -1,16 +1,18 @@ __version__ = "3.0.0b1" -from reactpy_router.components import link, navigate, route -from reactpy_router.hooks import use_params, use_search_params +from reactpy_router.components import form, link, navigate, route +from reactpy_router.hooks import use_form_data, use_params, use_search_params from reactpy_router.routers import browser_router, create_router __all__ = ( "browser_router", "create_router", + "form", "link", "navigate", "route", + "use_form_data", "use_params", "use_search_params", ) diff --git a/src/reactpy_router/components.py b/src/reactpy_router/components.py index 7235ad2..39ecfde 100644 --- a/src/reactpy_router/components.py +++ b/src/reactpy_router/components.py @@ -8,7 +8,7 @@ from reactpy.reactjs import component_from_file from reactpy.types import Location -from reactpy_router.hooks import _use_route_state +from reactpy_router.hooks import _use_form_data_state, _use_route_state from reactpy_router.types import Route if TYPE_CHECKING: @@ -27,6 +27,9 @@ ) """Client-side portion of the navigate component""" +FormJs = component_from_file(Path(__file__).parent / "static" / "bundle.js", import_names="Form", name="reactpy-router") +"""Client-side portion of form handling""" + def link(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> Component: """ @@ -68,6 +71,46 @@ def on_click_callback(_event: dict[str, Any]) -> None: return html(Link({"onClickCallback": on_click_callback, "linkClass": class_name}), html.a(attrs, *children)) +def form(attributes: dict[str, Any], *children: Any, key: Key | None = None) -> Component: + """ + Create a form element that intercepts submissions and navigates to the + action URL with form data serialized as query parameters. + + Args: + attributes: A dictionary of attributes for the form element. + *children: Child elements to be included within the form. + + Returns: + A form component with the specified attributes and children. + """ + return _form(attributes, *children, key=key) + + +@component +def _form(attributes: dict[str, Any], *children: Any) -> VdomDict: + attributes = attributes.copy() + class_name = use_ref(f"form-{uuid4().hex}").current + route_state = _use_route_state() + form_data_state = _use_form_data_state() + if "className" in attributes: + class_name = " ".join([attributes.pop("className"), class_name]) + + attrs = { + **attributes, + "className": class_name, + } + + def on_submit_callback(event: dict[str, Any]) -> None: + form_data_state.set_form_data(event.get("form_data", {})) + location_data = event.get("location", {}) + route_state.set_location(Location(**location_data)) + + return html( + FormJs({"onSubmitCallback": on_submit_callback, "formClass": class_name}), + html.form(attrs, *children), + ) + + def route(path: str, element: Any | None, *routes: Route) -> Route: """ Create a route with the given path, element, and child routes. diff --git a/src/reactpy_router/hooks.py b/src/reactpy_router/hooks.py index 725a8a9..7a42602 100644 --- a/src/reactpy_router/hooks.py +++ b/src/reactpy_router/hooks.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs @@ -8,12 +9,31 @@ from reactpy_router.types import RouteState # noqa: TC001 if TYPE_CHECKING: + from collections.abc import Callable + from reactpy.types import Context _route_state_context: Context[RouteState | None] = create_context(None) +@dataclass +class FormDataState: + """ + Holds form data state for the current route. + + Attributes: + form_data: A dictionary of form field names to lists of values. + set_form_data: A callable to update the form data. + """ + + form_data: dict[str, list[str]] + set_form_data: Callable[[dict[str, list[str]]], None] + + +_form_data_state_context: Context[FormDataState | None] = create_context(None) + + def _use_route_state() -> RouteState: route_state = use_context(_route_state_context) if route_state is None: # pragma: no cover @@ -26,6 +46,18 @@ def _use_route_state() -> RouteState: return route_state +def _use_form_data_state() -> FormDataState: + form_data_state = use_context(_form_data_state_context) + if form_data_state is None: # pragma: no cover + msg = ( + "ReactPy-Router was unable to find a form data context. Are you " + "sure this hook/component is being called within a router?" + ) + raise RuntimeError(msg) + + return form_data_state + + def use_params() -> dict[str, Any]: """This hook returns an object of key/value pairs of the dynamic parameters \ from the current URL that were matched by the `Route`. Child routes inherit all parameters \ @@ -41,6 +73,19 @@ def use_params() -> dict[str, Any]: return _use_route_state().params +def use_form_data() -> dict[str, list[str]]: + """ + This hook returns the form data from the most recent `Form` component + submission. The returned value is a dictionary of field names to lists of values. + + If no form has been submitted yet, an empty dictionary is returned. + + Returns: + A dictionary of form field names to lists of values. + """ + return _use_form_data_state().form_data + + def use_search_params( keep_blank_values: bool = False, strict_parsing: bool = False, diff --git a/src/reactpy_router/routers.py b/src/reactpy_router/routers.py index 4868178..f0bf9bf 100644 --- a/src/reactpy_router/routers.py +++ b/src/reactpy_router/routers.py @@ -11,7 +11,7 @@ from reactpy.types import Component, Connection, Location, VdomDict from reactpy_router.components import History -from reactpy_router.hooks import RouteState, _route_state_context +from reactpy_router.hooks import FormDataState, RouteState, _form_data_state_context, _route_state_context from reactpy_router.resolvers import ReactPyResolver if TYPE_CHECKING: @@ -61,6 +61,7 @@ def router( initial = use_connection() location, set_location = use_state(initial.location) + form_data, set_form_data = use_state({}) resolvers = use_memo( lambda: tuple(map(resolver, _iter_routes(routes))), dependencies=(resolver, hash(routes)), @@ -84,7 +85,13 @@ def on_history_previous(event: dict[str, Any]) -> None: return ConnectionContext( History({"onHistoryPreviousCallback": on_history_previous}), # type: ignore[return-value] - _route_state_context(match.element, value=RouteState(set_location, match.params)), + _route_state_context( + _form_data_state_context( + match.element, + value=FormDataState(form_data, set_form_data), + ), + value=RouteState(set_location, match.params), + ), value=Connection(initial.scope, location or initial.location, initial.carrier), ) diff --git a/tests/test_router.py b/tests/test_router.py index 0d13e91..78737dc 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -6,7 +6,7 @@ from reactpy import Ref, component, html, use_location, use_state from reactpy.testing import DisplayFixture -from reactpy_router import browser_router, link, navigate, route, use_params, use_search_params +from reactpy_router import browser_router, form, link, navigate, route, use_form_data, use_params, use_search_params GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "").lower() == "true" pytestmark = pytest.mark.anyio @@ -409,6 +409,121 @@ def sample(): assert await display.page.text_content("#nav-to-a") == "Go to A" +async def test_simple_form(display: DisplayFixture): + @component + def sample(): + return browser_router( + route( + "/", + form( + {"action": "/search", "id": "search-form"}, + html.input({"name": "q", "type": "text", "value": "hello"}), + html.button({"type": "submit", "id": "submit-btn"}, "Search"), + ), + ), + route("/search", html.h1({"id": "search-result"}, "Search Results")), + ) + + await display.show(sample) + + submit = await display.page.wait_for_selector("#submit-btn") + await submit.click() + + await display.page.wait_for_selector("#search-result") + assert await display.page.text_content("#search-result") == "Search Results" + + +async def test_form_use_form_data(display: DisplayFixture): + captured: dict[str, Any] = {} + + @component + def show_form_data(): + captured.update(use_form_data()) + return html.h1({"id": "form-data-result"}, str(captured)) + + @component + def sample(): + return browser_router( + route( + "/", + form( + {"action": "/result", "id": "test-form"}, + html.input({"name": "username", "type": "text", "value": "testuser"}), + html.input({"name": "role", "type": "text", "value": "admin"}), + html.button({"type": "submit", "id": "form-submit"}, "Submit"), + ), + ), + route("/result", show_form_data()), + ) + + await display.show(sample) + + submit = await display.page.wait_for_selector("#form-submit") + await submit.click() + + await display.page.wait_for_selector("#form-data-result") + assert captured == {"username": ["testuser"], "role": ["admin"]} + + +async def test_form_with_multiple_values(display: DisplayFixture): + """Test form submission with multiple values for the same field (e.g. checkboxes).""" + captured: dict[str, Any] = {} + + @component + def show_form_data(): + captured.update(use_form_data()) + return html.h1({"id": "form-data-result"}, str(captured)) + + @component + def sample(): + return browser_router( + route( + "/", + form( + {"action": "/result", "id": "multi-form"}, + html.input({"name": "color", "type": "checkbox", "value": "red", "checked": True}), + html.input({"name": "color", "type": "checkbox", "value": "blue", "checked": True}), + html.button({"type": "submit", "id": "multi-submit"}, "Submit"), + ), + ), + route("/result", show_form_data()), + ) + + await display.show(sample) + + submit = await display.page.wait_for_selector("#multi-submit") + await submit.click() + + await display.page.wait_for_selector("#form-data-result") + assert captured == {"color": ["red", "blue"]} + + +async def test_form_class_name(display: DisplayFixture): + @component + def sample(): + return browser_router( + route( + "/", + form( + {"action": "/a", "className": "my-form-class", "id": "my-form"}, + html.button({"type": "submit", "id": "submit-btn"}, "Submit"), + ), + ), + route("/a", html.h1({"id": "a"}, "A")), + ) + + await display.show(sample) + + form_el = await display.page.wait_for_selector("#my-form") + class_attr = await form_el.get_attribute("class") + assert class_attr is not None + assert "my-form-class" in class_attr + + submit = await display.page.wait_for_selector("#submit-btn") + await submit.click() + await display.page.wait_for_selector("#a") + + async def test_navigate_component_go_forward(display: DisplayFixture): """Navigate forward using navigate(1).""" From 6badfd5f1e19089084a710788f9674eaaba0f2d8 Mon Sep 17 00:00:00 2001 From: Archmonger Date: Wed, 22 Jul 2026 17:50:34 -0700 Subject: [PATCH 2/2] test: add render-count and action-url query string form tests --- tests/test_router.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/test_router.py b/tests/test_router.py index 78737dc..8cf9652 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -433,6 +433,65 @@ def sample(): assert await display.page.text_content("#search-result") == "Search Results" +async def test_form_no_page_reload(display: DisplayFixture): + """Verify that form submission does not cause a full page reload.""" + render_count = Ref(0) + + @component + def sample(): + render_count.current += 1 + return browser_router( + route( + "/", + form( + {"action": "/a", "id": "test-form"}, + html.input({"name": "q", "type": "text", "value": "hello"}), + html.button({"type": "submit", "id": "submit-btn"}, "Search"), + ), + ), + route("/a", html.h1({"id": "a"}, "A")), + ) + + await display.show(sample) + + submit = await display.page.wait_for_selector("#submit-btn") + await submit.click() + + await display.page.wait_for_selector("#a") + assert render_count.current == 1 + + +async def test_form_action_url_with_query_string(display: DisplayFixture): + """Test form action URL with existing query string.""" + + @component + def check_search_params(): + query = use_search_params() + assert query == {"existing": ["1"], "q": ["hello"]} + return html.h1({"id": "success"}, "success") + + @component + def sample(): + return browser_router( + route( + "/", + form( + {"action": "/search?existing=1", "id": "test-form"}, + html.input({"name": "q", "type": "text", "value": "hello"}), + html.button({"type": "submit", "id": "submit-btn"}, "Search"), + ), + ), + route("/search", check_search_params()), + ) + + await display.show(sample) + + submit = await display.page.wait_for_selector("#submit-btn") + await submit.click() + + await display.page.wait_for_selector("#success") + + async def test_form_use_form_data(display: DisplayFixture): captured: dict[str, Any] = {}