Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Don't forget to remove deprecated code on each major release!

### Changed

- `view_to_iframe` now renders its `iframe` without a `loading` attribute, so it loads **eagerly** (the HTML default) instead of lazily. The `iframe`'s document is now fetched as part of the parent page's initial load and is guaranteed to be available as soon as the page loads, rather than being deferred until it is scrolled close to the viewport. To restore the previous lazy-loading behavior, pass `extra_props={"loading": "lazy"}`.
- Use one WebSocket per client webpage.
- Updated dependencies: `reactpy>=2.0.0, <3.0.0` and `reactpy-router>=3.0.0, <4.0.0`.
- Updated Python support to 3.11–3.14.
Expand Down
1 change: 1 addition & 0 deletions docs/src/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ linters
linting
formatters
bootstrap_form
viewport
7 changes: 6 additions & 1 deletion src/reactpy_django/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ def constructor(

def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None) -> ViewToIframeConstructor:
"""
Converts a Django view into a ReactPy component that is rendered within an `iframe` element.

The `iframe` loads eagerly by default, so its document is fetched as part of the parent page's
initial load and is available as soon as the page loads. If you want the browser to defer the
iframe's fetch until it is scrolled close to the viewport, pass `extra_props={"loading": "lazy"}`.

Args:
view: The view function or class to convert, or the dotted path to the view.

Expand Down Expand Up @@ -248,7 +254,6 @@ def _view_to_iframe(
{
"src": reverse("reactpy:view_to_iframe", args=[dotted_path]) + query_string,
"style": {"border": "none"},
"loading": "lazy",
}
| extra_props
)
Expand Down
70 changes: 42 additions & 28 deletions tests/test_app/tests/test_components.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# type: ignore
# ruff: noqa: RUF012
from __future__ import annotations

import os
import socket
from uuid import uuid4
Expand Down Expand Up @@ -769,8 +771,7 @@ def test_form_basic(self):
self.page.wait_for_selector("#id_password_field")
self.page.wait_for_selector("#id_model_choice_field")
self.page.wait_for_selector("#id_model_multiple_choice_field")
self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
self.page.wait_for_selector(".errorlist")
self._retry_submit_until(".errorlist", fill=None)

# Submitting an empty form should result in 22 error elements.
# The number of errors may change if/when new test form elements are created.
Expand Down Expand Up @@ -857,8 +858,7 @@ def test_form_bootstrap(self):
self.page.wait_for_selector("#id_boolean_field")
self.page.wait_for_selector("#id_char_field")
self.page.wait_for_selector("#id_choice_field")
self.page.wait_for_selector("button[type=submit]").click(delay=DELAY)
self.page.wait_for_selector(".invalid-feedback")
self._retry_submit_until(".invalid-feedback", fill=None, submit_selector="button[type=submit]")

# Submitting an empty form should result in 2 error elements.
# The number of errors may change if/when new test form elements are created.
Expand All @@ -881,8 +881,7 @@ def test_form_bootstrap(self):
def test_form_orm_model(self):
uuid = uuid4().hex
self.page.wait_for_selector("form")
self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
self.page.wait_for_selector(".errorlist")
self._retry_submit_until(".errorlist", fill=None)

# Submitting an empty form should result in 1 error element.
error_list = self.page.locator(".errorlist").all()
Expand Down Expand Up @@ -910,20 +909,27 @@ def test_form_orm_model(self):
finally:
os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE")

def _retry_until_true(self, selector: str) -> None:
"""Poll for `#success[data-value='true']` after submitting the filled form.

Filling the `char_field` triggers the form's `onChange` handler, which causes an
async server re-render that reconciles (and can transiently remount) the submit
button. A single Playwright click on `input[type=submit]` may therefore land on
the form element instead of the button, swallowing the `submit` event and losing
the valid submission. This helper re-fills the field and re-clicks submit until
the `on_success` callback actually takes effect, making the interaction
deterministic instead of racing the reconciliation.
def _retry_submit_until(
self, selector: str, *, fill: str | None, submit_selector: str = "input[type=submit]"
) -> None:
"""Re-submit the form until an awaited state ``selector`` becomes visible.

The ``DjangoForm`` client component registers a native ``submit`` listener that
calls ``preventDefault()`` and forwards the form data over the WebSocket. That
listener is attached in ``componentDidMount`` and can momentarily lag the
rendered ``<form>`` (and the button can transiently re-mount after an
``onChange``-triggered async re-render). A click landing during either window
performs a *native* form submission: the browser reloads the page, which resets
all ReactPy state, so the awaited callback never appears. Retry the submit
(re-typing ``char_field`` when ``fill`` is set) until ``selector`` is satisfied
instead of racing the mount/reconcile. Pass ``fill=None`` to submit unchanged,
and ``submit_selector`` when the submit control is not ``input[type=submit]``
(e.g. the bootstrap form uses ``button[type=submit]``).
"""
for _ in range(3):
self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY)
self.page.wait_for_selector("input[type=submit]").click(delay=DELAY)
if fill is not None:
self.page.wait_for_selector("#id_char_field").type(fill, delay=DELAY)
self.page.wait_for_selector(submit_selector).click(delay=DELAY)
try:
self.page.wait_for_selector(selector, timeout=5000)
return
Expand All @@ -942,19 +948,23 @@ def test_form_sync_events(self):
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)
# Submit the empty form and wait for the `on_error` callback to take effect. The
# client-side `preventDefault` submit interceptor can momentarily lag the rendered
# <form> under load, so a click that lands first performs a native submit that
# reloads the page and resets state. Re-click the (still-empty) submit until the
# error surfaces rather than racing the mount.
self._retry_submit_until("#error[data-value='true']", fill=None)

# The empty form was submitted, should result in an error
# Empty submit is invalid: `on_error` and `on_receive_data` fired, but nothing was
# typed so `on_change` never fired and nothing succeeded.
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. The `onChange`-triggered async re-render can
# transiently remount the submit button, so retry (re-filling + re-clicking) until
# the `on_success` callback visibly takes effect rather than racing the reconcile.
self._retry_until_true("#success[data-value='true']")
self._retry_submit_until("#success[data-value='true']", fill="test")

# Form should have been successfully submitted
self.page.wait_for_selector("#error[data-value='true']")
Expand All @@ -971,19 +981,23 @@ def test_form_async_events(self):
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)
# Submit the empty form and wait for the `on_error` callback to take effect. The
# client-side `preventDefault` submit interceptor can momentarily lag the rendered
# <form> under load, so a click that lands first performs a native submit that
# reloads the page and resets state. Re-click the (still-empty) submit until the
# error surfaces rather than racing the mount.
self._retry_submit_until("#error[data-value='true']", fill=None)

# The empty form was submitted, should result in an error
# Empty submit is invalid: `on_error` and `on_receive_data` fired, but nothing was
# typed so `on_change` never fired and nothing succeeded.
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. The `onChange`-triggered async re-render can
# transiently remount the submit button, so retry (re-filling + re-clicking) until
# the `on_success` callback visibly takes effect rather than racing the reconcile.
self._retry_until_true("#success[data-value='true']")
self._retry_submit_until("#success[data-value='true']", fill="test")

# Form should have been successfully submitted
self.page.wait_for_selector("#error[data-value='true']")
Expand Down
Loading