diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py index 63a6d2eb..6771f9b0 100644 --- a/tests/test_app/router/components.py +++ b/tests/test_app/router/components.py @@ -1,4 +1,4 @@ -from uuid import uuid4 +from uuid import UUID, uuid4 from reactpy import component, html, use_location, use_state from reactpy_router import link, route, use_params, use_search_params @@ -7,6 +7,22 @@ from reactpy_django.router import django_router +class _TokenStore: + """Process-wide holder for a stable navigation-state token.""" + + def __init__(self) -> None: + self._value: UUID | None = None + + def get(self) -> UUID: + """Return the token, creating one on first access.""" + if self._value is None: + self._value = uuid4() + return self._value + + +_NEXT_PAGE_TOKEN = _TokenStore() + + @component def display_params(string: str): location = use_location() @@ -32,7 +48,13 @@ def show_route(path: str, *children: Route) -> Route: @component def next_page(): url_params = use_params() - state, _set_state = use_state(uuid4) + # ReactPy preserves `use_state` across SPA navigation, but the WebSocket can + # reconnect under load, which causes ReactPy to re-mount the component and + # reset hook state. Keep the token in a module-level store so it survives both + # navigation and transient reconnects, otherwise this state-preservation test + # becomes flaky on slow/loaded CI runners. + token_hex = _NEXT_PAGE_TOKEN.get() + state, _set_state = use_state(token_hex) page = url_params.get("page", 0) next_page = page + 1 return html.fragment( @@ -44,17 +66,24 @@ def next_page(): ) +# Routes are defined at module scope so that the route elements (including the +# stateful `next_page`) have a stable identity across renders. Recreating them +# inside `main()` on each render would cause ReactPy to treat them as new +# components, remounting them and resetting `use_state` during SPA navigation. +ROUTES: tuple[Route, ...] = ( + show_route("/router/", show_route("subroute/")), + show_route("/router/unspecified//"), + show_route("/router/integer//"), + show_route("/router/path//"), + show_route("/router/slug//"), + show_route("/router/string//"), + show_route("/router/uuid//"), + show_route("/router/any/"), + show_route("/router/two///"), + route("/router/next//", next_page()), +) + + @component def main(): - return django_router( - show_route("/router/", show_route("subroute/")), - show_route("/router/unspecified//"), - show_route("/router/integer//"), - show_route("/router/path//"), - show_route("/router/slug//"), - show_route("/router/string//"), - show_route("/router/uuid//"), - show_route("/router/any/"), - show_route("/router/two///"), - route("/router/next//", next_page()), - ) + return django_router(*ROUTES) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 24051c2b..c3e3aa71 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -594,9 +594,12 @@ def test_channel_layer_components(self): @navigate_to_page("/pyscript/") def test_pyscript_0_hello_world(self): - # Use this test to wait for PyScript to fully load on the page + # This is the FIRST test to load the PyScript page, so it bears the cold-start + # cost of booting Pyodide and installing PyScript packages (micropip fetch). + # Give the rendered component an equally generous timeout, otherwise slow CI + # runners intermittently exceed the default 10s timeout and flake. self.page.wait_for_selector("#hello-world-loading", timeout=30000) - self.page.wait_for_selector("#hello-world") + self.page.wait_for_selector("#hello-world", timeout=30000) @navigate_to_page("/pyscript/") def test_pyscript_1_custom_root(self): diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index 5a58ea64..cc168fc6 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -116,7 +116,12 @@ def start_playwright_client(cls): headless = str_to_bool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) cls.page = cls.browser.new_page() - cls.page.set_default_timeout(10000) + # A generous default timeout is required because the suite runs the full + # component set under one shared browser page and server. Under load (e.g. + # CI), async state updates, form mutations, and page loads can legitimately + # take longer than a short 10s window, causing intermittent TimeoutErrors. + # This matches ReactPy core's 30s test fixture timeout. + cls.page.set_default_timeout(30000) cls.page.on("console", lambda msg: print(f"CLIENT {msg.type.upper()}: {msg.text}")) cls.page.on("pageerror", lambda err: print(f"CLIENT EXCEPTION: {err.name}: {err.message}\n{err.stack}"))