From e6c84719afa5a608c1f9f2cfe60bce5319eefb21 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 22 Sep 2026 20:44:38 +0000 Subject: [PATCH 1/5] Load `view_to_iframe` iframes eagerly by default `view_to_iframe` previously forced `loading="lazy"` on the rendered iframe. The browser only fetches a lazy iframe's document once it is scrolled close to the viewport. On the home page these iframes sit well below the fold, and the test harness performs no navigation (and no scroll) when it is already on "/", so a deferred lazy iframe is never fetched and a consumer waiting on the iframe's contents times out (observed intermittently on slow CI runners, e.g. `test_component_view_to_iframe_sync_func` on windows-latest). An eager iframe (the HTML default, no `loading` attribute) is a sub-resource the browser fetches as part of the parent page's initial load, so its document is guaranteed to be present as soon as the page loads. This makes loading deterministic instead of depending on the browser's load-timing heuristics, which is the real root cause of the flake. Lazy loading remains available as an opt-in for users who prefer it: pass `extra_props={"loading": "lazy"}`. --- CHANGELOG.md | 1 + docs/src/dictionary.txt | 1 + docs/src/reference/components.md | 4 ++++ src/reactpy_django/components.py | 14 +++++++++++++- 4 files changed, 19 insertions(+), 1 deletion(-) 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/docs/src/reference/components.md b/docs/src/reference/components.md index 7fb681fa..fbcbe803 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -301,6 +301,10 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. - The `#!python iframe` will always load **after** the parent component. - CSS styling for `#!python iframe` elements tends to be awkward. +??? info "Loading behavior" + + The `#!python iframe` loads **eagerly** by default: its document is fetched as part of the parent page's initial load, so it is available as soon as the page loads. If you want the browser to defer fetching the `#!python iframe`'s document until it is scrolled close to the viewport, pass `#!python extra_props={"loading": "lazy"}`. + ??? question "How do I use this for Class Based Views?" Class Based Views are accepted by `#!python view_to_iframe` as an argument. diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index a25a5adf..62db98e5 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. @@ -244,11 +250,17 @@ def _view_to_iframe( extra_props = extra_props or {} extra_props.pop("src", None) + # The iframe loads eagerly by default (the HTML default). An eager iframe is + # a sub-resource that the browser fetches as part of the parent page's + # initial load, so its document is reliably present as soon as the page + # loads. The previous default of `loading="lazy"` deferred the fetch until + # the iframe was scrolled close to the viewport, which made availability + # non-deterministic. If you want that deferred behavior, opt in via + # `extra_props={"loading": "lazy"}`. return html.iframe( { "src": reverse("reactpy:view_to_iframe", args=[dotted_path]) + query_string, "style": {"border": "none"}, - "loading": "lazy", } | extra_props ) From 871083820442117bfbd9a9406f1a342ba0caface Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 22 Sep 2026 14:09:19 -0700 Subject: [PATCH 2/5] Update components.md --- docs/src/reference/components.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index fbcbe803..7fb681fa 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -301,10 +301,6 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. - The `#!python iframe` will always load **after** the parent component. - CSS styling for `#!python iframe` elements tends to be awkward. -??? info "Loading behavior" - - The `#!python iframe` loads **eagerly** by default: its document is fetched as part of the parent page's initial load, so it is available as soon as the page loads. If you want the browser to defer fetching the `#!python iframe`'s document until it is scrolled close to the viewport, pass `#!python extra_props={"loading": "lazy"}`. - ??? question "How do I use this for Class Based Views?" Class Based Views are accepted by `#!python view_to_iframe` as an argument. From 0b4f2d511d82c66b5da1ded7f64ecefc991b5851 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 22 Sep 2026 14:09:50 -0700 Subject: [PATCH 3/5] Update components.py --- src/reactpy_django/components.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 62db98e5..b59faeae 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -250,13 +250,6 @@ def _view_to_iframe( extra_props = extra_props or {} extra_props.pop("src", None) - # The iframe loads eagerly by default (the HTML default). An eager iframe is - # a sub-resource that the browser fetches as part of the parent page's - # initial load, so its document is reliably present as soon as the page - # loads. The previous default of `loading="lazy"` deferred the fetch until - # the iframe was scrolled close to the viewport, which made availability - # non-deterministic. If you want that deferred behavior, opt in via - # `extra_props={"loading": "lazy"}`. return html.iframe( { "src": reverse("reactpy:view_to_iframe", args=[dotted_path]) + query_string, From f41bda5776059023d80db1eec4ed2729e488a0ef Mon Sep 17 00:00:00 2001 From: User Date: Wed, 23 Sep 2026 02:08:43 +0000 Subject: [PATCH 4/5] Fix flaky `test_form_sync_events` by retrying the empty-form submit `test_form_sync_events` intermittently times out waiting for `#error[data-value='true']` after submitting the empty form. The `DjangoForm` client component registers a native `submit` listener that calls `preventDefault()` and forwards the form data over the WebSocket, but the listener is attached in `componentDidMount` and can momentarily lag the rendered `
` under load. A click landing during that window performs a native form submission: the browser reloads the page (a second `Starting client...` with no preceding `Disconnected!`, matching the CI signature), which resets all ReactPy state so `on_error` never fires. The existing retry guard only covered the later *filled* re-submit. Extend it into a shared `_retry_submit_until(selector, fill=...)` helper and use it for the empty-submit path too (and, symmetrically, for the async-event form which shares the same interaction). Re-clicking the submit until the awaited callback surfaces makes the interaction deterministic instead of racing the mount. Test-only change; no product code is touched. --- tests/test_app/tests/test_components.py | 55 +++++++++++++++---------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index d7bde5e6..e21c8448 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 @@ -910,19 +912,22 @@ 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) -> 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. """ for _ in range(3): - self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + if fill is not None: + self.page.wait_for_selector("#id_char_field").type(fill, delay=DELAY) self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) try: self.page.wait_for_selector(selector, timeout=5000) @@ -942,19 +947,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 +980,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']") From a3c91e06feba6fd4e47aa6024a2911d07fcb809c Mon Sep 17 00:00:00 2001 From: User Date: Wed, 23 Sep 2026 02:36:24 +0000 Subject: [PATCH 5/5] Guard remaining empty-form submits against the same native-reload race The reviewer flagged that `test_form_basic`, `test_form_bootstrap`, and `test_form_orm_model` use the identical bare `submit -> wait_for(error)` pattern as `test_form_sync_events`, so they share the same latent exposure to a native form submission (which reloads the page and resets state) when the `DjangoForm` `componentDidMount` submit listener has not yet attached under load. Extend the shared `_retry_submit_until` guard to those three sites as well. Added a `submit_selector` parameter because the bootstrap form uses `button[type=submit]` instead of `input[type=submit]`. Test-only; no product code touched. Verified: `hatch fmt` clean, full `test_components.py` green under CPU-contention load (74 passed), and the 5-test form family green across repeated load runs. --- tests/test_app/tests/test_components.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index e21c8448..82d13673 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -771,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. @@ -859,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. @@ -883,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() @@ -912,7 +909,9 @@ def test_form_orm_model(self): finally: os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") - def _retry_submit_until(self, selector: str, *, fill: str | None) -> None: + 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 @@ -923,12 +922,14 @@ def _retry_submit_until(self, selector: str, *, fill: str | None) -> None: 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. + 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): if fill is not None: self.page.wait_for_selector("#id_char_field").type(fill, delay=DELAY) - self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) + self.page.wait_for_selector(submit_selector).click(delay=DELAY) try: self.page.wait_for_selector(selector, timeout=5000) return