From 8cbee2a36bd303b56d78dc0d9b8e713f3b85df93 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:07:01 -0700 Subject: [PATCH 1/2] fix #272 --- CHANGELOG.md | 4 + src/reactpy_django/forms/components.py | 25 ++++- tests/test_app/tests/test_components.py | 117 ++++++++++++------------ 3 files changed, 82 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9934c339..2d3482c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ Don't forget to remove deprecated code on each major release! - Removed `reactpy_django/html.py` (`pyscript` VDOM constructor). Use `html.py_script` instead. - Removed `nest_asyncio` dependency and its initialization on startup. +### Fixed + +- Resolved bug where `django_form` events would sometimes not occur. + ### [5.2.1] - 2025-01-10 ### Changed diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py index 212493e9..f49e4c66 100644 --- a/src/reactpy_django/forms/components.py +++ b/src/reactpy_django/forms/components.py @@ -1,5 +1,6 @@ from __future__ import annotations +from asyncio import iscoroutinefunction from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Union, cast @@ -65,6 +66,14 @@ def _django_form( form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data ) + def _call_callback(callback, event_data): + """Call a form event callback, running sync callbacks directly + (not through database_sync_to_async) to avoid issues with ReactPy state + setters that require an active event loop.""" + if iscoroutinefunction(callback): + return callback(event_data) + return callback(event_data) + # Validate and render the form @hooks.use_async_effect(dependencies=[str(submitted_data)]) async def render_form(): @@ -76,9 +85,13 @@ async def render_form(): await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() success = not initialized_form.errors.as_data() if success and on_success: - await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) + result = _call_callback(on_success, form_event) + if iscoroutinefunction(on_success): + await result if not success and on_error: - await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event) + result = _call_callback(on_error, form_event) + if iscoroutinefunction(on_error): + await result if success and auto_save and isinstance(initialized_form, ModelForm): await ensure_async(initialized_form.save)() set_submitted_data(None) @@ -101,7 +114,9 @@ async def on_submit_callback(new_data: dict[str, Any]): new_form_event = FormEventData( form=initialized_form, submitted_data=new_data, set_submitted_data=set_submitted_data ) - await ensure_async(on_receive_data, thread_sensitive=thread_sensitive)(new_form_event) + result = _call_callback(on_receive_data, new_form_event) + if iscoroutinefunction(on_receive_data): + await result if submitted_data != new_data: set_submitted_data(new_data) @@ -109,7 +124,9 @@ async def on_submit_callback(new_data: dict[str, Any]): async def _on_change(_event): """Event that exist solely to allow the user to detect form changes.""" if on_change: - await ensure_async(on_change, thread_sensitive=thread_sensitive)(form_event) + result = _call_callback(on_change, form_event) + if iscoroutinefunction(on_change): + await result if not rendered_form: return None diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 66efbad8..7d046330 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -859,63 +859,60 @@ def test_form_orm_model(self): finally: os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") - # FIXME: Re-enable once we fix the following: - # https://github.com/reactive-python/reactpy-django/issues/272 - - # @navigate_to_page("/form/sync_event/") - # def test_form_sync_events(self): - # self.page.wait_for_selector("form") - - # # Check initial state - # self.page.wait_for_selector("#success[data-value='false']") - # self.page.wait_for_selector("#error[data-value='false']") - # self.page.wait_for_selector("#receive_data[data-value='false']") - # self.page.wait_for_selector("#change[data-value='false']") - - # # Submit empty the form - # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) - - # # The empty form was submitted, should result in an error - # self.page.wait_for_selector("#success[data-value='false']") - # self.page.wait_for_selector("#error[data-value='true']") - # self.page.wait_for_selector("#receive_data[data-value='true']") - # self.page.wait_for_selector("#change[data-value='false']") - - # # Fill out the form and re-submit - # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) - # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) - - # # Form should have been successfully submitted - # self.page.wait_for_selector("#success[data-value='true']") - # self.page.wait_for_selector("#error[data-value='true']") - # self.page.wait_for_selector("#receive_data[data-value='true']") - # self.page.wait_for_selector("#change[data-value='true']") - - # @navigate_to_page("/form/async_event/") - # def test_form_async_events(self): - # self.page.wait_for_selector("form") - - # # Check initial state - # self.page.wait_for_selector("#success[data-value='false']") - # self.page.wait_for_selector("#error[data-value='false']") - # self.page.wait_for_selector("#receive_data[data-value='false']") - # self.page.wait_for_selector("#change[data-value='false']") - - # # Submit empty the form - # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) - - # # The empty form was submitted, should result in an error - # self.page.wait_for_selector("#success[data-value='false']") - # self.page.wait_for_selector("#error[data-value='true']") - # self.page.wait_for_selector("#receive_data[data-value='true']") - # self.page.wait_for_selector("#change[data-value='false']") - - # # Fill out the form and re-submit - # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) - # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) - - # # Form should have been successfully submitted - # self.page.wait_for_selector("#success[data-value='true']") - # self.page.wait_for_selector("#error[data-value='true']") - # self.page.wait_for_selector("#receive_data[data-value='true']") - # self.page.wait_for_selector("#change[data-value='true']") + @navigate_to_page("/form/sync_event/") + def test_form_sync_events(self): + self.page.wait_for_selector("form") + + # Check initial state + self.page.wait_for_selector("#success[data-value='false']") + self.page.wait_for_selector("#error[data-value='false']") + self.page.wait_for_selector("#receive_data[data-value='false']") + self.page.wait_for_selector("#change[data-value='false']") + + # Submit empty the form + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) + + # The empty form was submitted, should result in an error + self.page.wait_for_selector("#success[data-value='false']") + self.page.wait_for_selector("#error[data-value='true']") + self.page.wait_for_selector("#receive_data[data-value='true']") + self.page.wait_for_selector("#change[data-value='false']") + + # Fill out the form and re-submit + self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) + + # Form should have been successfully submitted + self.page.wait_for_selector("#success[data-value='true']") + self.page.wait_for_selector("#error[data-value='true']") + self.page.wait_for_selector("#receive_data[data-value='true']") + self.page.wait_for_selector("#change[data-value='true']") + + @navigate_to_page("/form/async_event/") + def test_form_async_events(self): + self.page.wait_for_selector("form") + + # Check initial state + self.page.wait_for_selector("#success[data-value='false']") + self.page.wait_for_selector("#error[data-value='false']") + self.page.wait_for_selector("#receive_data[data-value='false']") + self.page.wait_for_selector("#change[data-value='false']") + + # Submit empty the form + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) + + # The empty form was submitted, should result in an error + self.page.wait_for_selector("#success[data-value='false']") + self.page.wait_for_selector("#error[data-value='true']") + self.page.wait_for_selector("#receive_data[data-value='true']") + self.page.wait_for_selector("#change[data-value='false']") + + # Fill out the form and re-submit + self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) + + # Form should have been successfully submitted + self.page.wait_for_selector("#success[data-value='true']") + self.page.wait_for_selector("#error[data-value='true']") + self.page.wait_for_selector("#receive_data[data-value='true']") + self.page.wait_for_selector("#change[data-value='true']") From 03765ce7cd43c8bd30c8a7fe37900dfd74d72884 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:12:11 -0700 Subject: [PATCH 2/2] fix type check errors --- src/reactpy_django/forms/components.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py index f49e4c66..fa3897d5 100644 --- a/src/reactpy_django/forms/components.py +++ b/src/reactpy_django/forms/components.py @@ -66,13 +66,14 @@ def _django_form( form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data ) - def _call_callback(callback, event_data): + async def _call_event_callback(callback, event_data): """Call a form event callback, running sync callbacks directly (not through database_sync_to_async) to avoid issues with ReactPy state setters that require an active event loop.""" if iscoroutinefunction(callback): - return callback(event_data) - return callback(event_data) + await callback(event_data) + else: + callback(event_data) # Validate and render the form @hooks.use_async_effect(dependencies=[str(submitted_data)]) @@ -85,13 +86,9 @@ async def render_form(): await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() success = not initialized_form.errors.as_data() if success and on_success: - result = _call_callback(on_success, form_event) - if iscoroutinefunction(on_success): - await result + await _call_event_callback(on_success, form_event) if not success and on_error: - result = _call_callback(on_error, form_event) - if iscoroutinefunction(on_error): - await result + await _call_event_callback(on_error, form_event) if success and auto_save and isinstance(initialized_form, ModelForm): await ensure_async(initialized_form.save)() set_submitted_data(None) @@ -114,9 +111,7 @@ async def on_submit_callback(new_data: dict[str, Any]): new_form_event = FormEventData( form=initialized_form, submitted_data=new_data, set_submitted_data=set_submitted_data ) - result = _call_callback(on_receive_data, new_form_event) - if iscoroutinefunction(on_receive_data): - await result + await _call_event_callback(on_receive_data, new_form_event) if submitted_data != new_data: set_submitted_data(new_data) @@ -124,9 +119,7 @@ async def on_submit_callback(new_data: dict[str, Any]): async def _on_change(_event): """Event that exist solely to allow the user to detect form changes.""" if on_change: - result = _call_callback(on_change, form_event) - if iscoroutinefunction(on_change): - await result + await _call_event_callback(on_change, form_event) if not rendered_form: return None