diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b67ef8..f0caebbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index e4025d3a..2971517b 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -52,3 +52,4 @@ linters linting formatters bootstrap_form +viewport diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index a25a5adf..b59faeae 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -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. @@ -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 ) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index d7bde5e6..82d13673 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,5 +1,7 @@ # type: ignore # ruff: noqa: RUF012 +from __future__ import annotations + import os import socket from uuid import uuid4 @@ -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. @@ -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. @@ -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() @@ -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 ``
`` (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 @@ -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 + # 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']") @@ -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 + # 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']")