From ff0ce6e9147ea7f814e5a754f12efaf6f9746b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:33:01 +0000 Subject: [PATCH 1/3] fix: sync router_data query/hash after window.history.replaceState React Router's location (mirrored in locationRef) does not observe direct window.history.pushState/replaceState calls (e.g. via rx.call_script), so State.router.url.query went stale after the URL query was updated that way. Populate router_data's query string and hash from the live window.location outside embed mode, while keeping the basename-relative pathname from React Router so frontend_path is not applied twice. Embedded (mount target) apps continue to use the in-widget memory router. Fixes #6603 https://claude.ai/code/session_01JwZCEa2bkfK9QDp4PP84d8 --- news/6603.bugfix.md | 1 + .../reflex_base/.templates/web/utils/state.js | 12 +- .../tests_playwright/test_router_query.py | 148 ++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 news/6603.bugfix.md create mode 100644 tests/integration/tests_playwright/test_router_query.py diff --git a/news/6603.bugfix.md b/news/6603.bugfix.md new file mode 100644 index 00000000000..27753bb13f8 --- /dev/null +++ b/news/6603.bugfix.md @@ -0,0 +1 @@ +Fixed `State.router.url.query` (and the rest of `router_data`) going stale after the URL was changed with `window.history.replaceState`/`pushState` (e.g. via `rx.call_script`). React Router's location does not observe direct history manipulation, so the query string and hash are now read from the live `window.location` when populating router data, while the pathname stays basename-relative so `frontend_path` is not applied twice. Embedded (mount target) apps continue to use the in-widget memory router. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index c07b4dfcbac..93fa01adbd4 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -390,8 +390,16 @@ export const applyEvent = async (event, socket, navigate, params) => { Object.keys(event.router_data).length === 0 ) { const loc = locationRef.current ?? window.location; - const search = loc.search ?? ""; - const hash = loc.hash ?? ""; + // React Router's location (mirrored in locationRef) does not observe direct + // window.history.pushState/replaceState calls (e.g. via rx.call_script), so + // read the live query string and hash to keep router_data in sync. The + // pathname stays basename-relative (from React Router) so the backend's + // frontend_path prefix is not applied twice. In embed mode the host page's + // window.location is unrelated to the in-widget memory router, so use the + // mirrored location there. + const liveLoc = env.MOUNT_TARGET ? loc : window.location; + const search = liveLoc.search ?? ""; + const hash = liveLoc.hash ?? ""; event.router_data = { pathname: loc.pathname, asPath: loc.pathname + search + hash, diff --git a/tests/integration/tests_playwright/test_router_query.py b/tests/integration/tests_playwright/test_router_query.py new file mode 100644 index 00000000000..d327e291bf4 --- /dev/null +++ b/tests/integration/tests_playwright/test_router_query.py @@ -0,0 +1,148 @@ +"""Integration test for router query sync after history.replaceState. + +Reproduces https://github.com/reflex-dev/reflex/issues/6603: updating the URL +query string with ``window.history.replaceState`` (e.g. via ``rx.call_script``) +should keep ``State.router.url.query`` in sync. React Router's ``useLocation`` +does not observe direct history manipulation, so the frontend must read the +live ``window.location`` query when populating ``router_data``. + +Covers dev and prod modes via ``app_harness_env`` parametrisation. +""" + +from __future__ import annotations + +import re +from collections.abc import Generator + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def RouterQueryApp(): + """App that mutates the URL query via replaceState and reads it back.""" + import reflex as rx + + class RouterQueryState(rx.State): + # The raw query string read from the router on the last read event. + query_str: str = "" + # The "name" query parameter read from the router on the last read event. + name_param: str = "" + + @rx.event + def replace_query(self, query: str): + """Update the browser URL query string without going through React Router. + + Args: + query: The query string to set (e.g. ``?name=test``). + + Returns: + A call_script event that runs window.history.replaceState. + """ + return rx.call_script(f"window.history.replaceState({{}}, '', {query!r})") + + @rx.event + def read_query(self): + """Read the current router query into state vars.""" + self.query_str = self.router.url.query + self.name_param = self.router.url.query_parameters.get("name", "") + + def index(): + return rx.box( + rx.input( + value=RouterQueryState.router.session.client_token, + read_only=True, + id="token", + ), + rx.button( + "set ?name=test", + on_click=RouterQueryState.replace_query("?name=test"), + id="set-name-test", + ), + rx.button( + "set ?name=other&page=2", + on_click=RouterQueryState.replace_query("?name=other&page=2"), + id="set-name-other", + ), + rx.button( + "read query", + on_click=RouterQueryState.read_query, + id="read-query", + ), + rx.input(value=RouterQueryState.query_str, read_only=True, id="query-str"), + rx.input( + value=RouterQueryState.name_param, read_only=True, id="name-param" + ), + ) + + app = rx.App() + app.add_page(index) + + +@pytest.fixture(scope="module") +def router_query_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, +) -> Generator[AppHarness, None, None]: + """Start the RouterQueryApp in dev or prod mode. + + Args: + app_harness_env: AppHarness (dev) or AppHarnessProd (prod). + tmp_path_factory: pytest fixture for creating temporary directories. + + Yields: + Running AppHarness instance. + """ + name = f"routerquery_{app_harness_env.__name__.lower()}" + with app_harness_env.create( + root=tmp_path_factory.mktemp(name), + app_name=name, + app_source=RouterQueryApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def _load(harness: AppHarness, page: Page) -> str: + """Navigate to the app root and wait for hydration. + + Args: + harness: The running AppHarness. + page: Playwright page. + + Returns: + The frontend base URL with any trailing slash stripped. + """ + base = harness.frontend_url + assert base is not None + base = base.rstrip("/") + page.goto(base) + expect(page.locator("#token")).not_to_have_value("") + return base + + +def test_replace_state_syncs_router_query(router_query_app: AppHarness, page: Page): + """router.url.query reflects query params set via history.replaceState.""" + _load(router_query_app, page) + + # Baseline: no query yet. + page.click("#read-query") + expect(page.locator("#query-str")).to_have_value("") + expect(page.locator("#name-param")).to_have_value("") + + # Update the URL query via replaceState (bypasses React Router). + page.click("#set-name-test") + expect(page).to_have_url(re.compile(r".*\?name=test$")) + + # The next event must carry the updated query to the backend. + page.click("#read-query") + expect(page.locator("#name-param")).to_have_value("test") + expect(page.locator("#query-str")).to_have_value("name=test") + + # A subsequent replaceState keeps the router in sync. + page.click("#set-name-other") + expect(page).to_have_url(re.compile(r".*\?name=other&page=2$")) + page.click("#read-query") + expect(page.locator("#name-param")).to_have_value("other") + expect(page.locator("#query-str")).to_have_value("name=other&page=2") From ee61c98100cfd9241f6e8140ae8f08b08e21f436 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:48:27 +0000 Subject: [PATCH 2/3] chore: move news fragment to reflex-base package The changed source lives in packages/reflex-base, so the towncrier fragment must live under packages/reflex-base/news/ and be named by PR number for the per-package changelog check. https://claude.ai/code/session_01JwZCEa2bkfK9QDp4PP84d8 --- news/6603.bugfix.md => packages/reflex-base/news/6625.bugfix.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename news/6603.bugfix.md => packages/reflex-base/news/6625.bugfix.md (100%) diff --git a/news/6603.bugfix.md b/packages/reflex-base/news/6625.bugfix.md similarity index 100% rename from news/6603.bugfix.md rename to packages/reflex-base/news/6625.bugfix.md From d28d49ad2b193d8af98973f6f420ef801115c53d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 21:01:36 +0000 Subject: [PATCH 3/3] test: codify router query sync and redirect navigation semantics Rework the router_query integration test per review feedback: - Use rx.var computed vars (router.url.query / query_parameters) rendered in the app, plus on_load and ping counters, to observe behavior directly. - Codify that a direct rx.call_script(history.replaceState) is NOT a navigation: it fires no event and no on_load, and the router only reflects the new URL on the next event sent to the backend. - Codify rx.redirect(target): client-side push navigation that fires on_load and updates the router reactively; back returns to the prior entry. - Codify rx.redirect(target, replace=True): same reactive behavior but replaces the current history entry (back skips it). Also clarify the news fragment: history mutation is intentionally not reactive; rx.redirect(replace=True) is the reactive path. https://claude.ai/code/session_01JwZCEa2bkfK9QDp4PP84d8 --- packages/reflex-base/news/6625.bugfix.md | 2 +- .../tests_playwright/test_router_query.py | 213 ++++++++++++++---- 2 files changed, 172 insertions(+), 43 deletions(-) diff --git a/packages/reflex-base/news/6625.bugfix.md b/packages/reflex-base/news/6625.bugfix.md index 27753bb13f8..b45d2e29228 100644 --- a/packages/reflex-base/news/6625.bugfix.md +++ b/packages/reflex-base/news/6625.bugfix.md @@ -1 +1 @@ -Fixed `State.router.url.query` (and the rest of `router_data`) going stale after the URL was changed with `window.history.replaceState`/`pushState` (e.g. via `rx.call_script`). React Router's location does not observe direct history manipulation, so the query string and hash are now read from the live `window.location` when populating router data, while the pathname stays basename-relative so `frontend_path` is not applied twice. Embedded (mount target) apps continue to use the in-widget memory router. +Fixed `State.router.url` reflecting a stale query string after the URL was changed with `window.history.replaceState`/`pushState` (e.g. from `rx.call_script`). React Router's location does not observe direct history manipulation, so the query and hash are now read from the live `window.location` when building `router_data`, and the next event sent to the backend reports the correct URL (the path stays basename-relative so `frontend_path` is not applied twice; embedded apps keep using the in-widget memory router). A direct history mutation is intentionally not a navigation and does not itself emit an event — use `rx.redirect(..., replace=True)` when you need the URL change to update the router reactively and trigger `on_load`. diff --git a/tests/integration/tests_playwright/test_router_query.py b/tests/integration/tests_playwright/test_router_query.py index d327e291bf4..50b83b94b25 100644 --- a/tests/integration/tests_playwright/test_router_query.py +++ b/tests/integration/tests_playwright/test_router_query.py @@ -1,10 +1,18 @@ -"""Integration test for router query sync after history.replaceState. +"""Integration tests for router URL/query sync and navigation semantics. -Reproduces https://github.com/reflex-dev/reflex/issues/6603: updating the URL -query string with ``window.history.replaceState`` (e.g. via ``rx.call_script``) -should keep ``State.router.url.query`` in sync. React Router's ``useLocation`` -does not observe direct history manipulation, so the frontend must read the -live ``window.location`` query when populating ``router_data``. +Reproduces and guards https://github.com/reflex-dev/reflex/issues/6603 and +codifies the agreed behavior of the navigation primitives: + +* A direct ``rx.call_script(window.history.replaceState(...))`` changes the + browser URL but is **not** a navigation: it fires no event, no ``on_load``, + and does not reactively update the router. The corrected URL/query is simply + observed by the **next** event sent to the backend (React Router's location + does not see direct history manipulation, so the frontend reads the live + ``window.location`` query when building ``router_data``). +* ``rx.redirect(target)`` performs a client-side navigation (push), fires + ``on_load``, and updates the router reactively with no further interaction. +* ``rx.redirect(target, replace=True)`` behaves the same but replaces the + current history entry instead of pushing a new one. Covers dev and prod modes via ``app_harness_env`` parametrisation. """ @@ -21,18 +29,43 @@ def RouterQueryApp(): - """App that mutates the URL query via replaceState and reads it back.""" + """App exercising replaceState, redirect, and redirect(replace=True).""" import reflex as rx class RouterQueryState(rx.State): - # The raw query string read from the router on the last read event. - query_str: str = "" - # The "name" query parameter read from the router on the last read event. - name_param: str = "" + # Incremented by the page on_load handler; proves whether a navigation + # (and thus on_load) actually fired. + load_count: int = 0 + # Incremented by an explicit, non-navigation event used to flush the + # next round-trip to the backend. + ping_count: int = 0 + + @rx.var + def query_str(self) -> str: + """The raw router query string (recomputes when the router changes). + + Returns: + The raw query string, without a leading ``?``. + """ + return self.router.url.query + + @rx.var + def name_param(self) -> str: + """The ``name`` query parameter from the router. + + Returns: + The value of the ``name`` query parameter, or ``""``. + """ + return self.router.url.query_parameters.get("name", "") + + @rx.event + def on_load(self): + """Record that the page on_load handler fired.""" + self.load_count += 1 @rx.event - def replace_query(self, query: str): - """Update the browser URL query string without going through React Router. + def replace_via_script(self, query: str): + """Change the URL query via history.replaceState (not a navigation). Args: query: The query string to set (e.g. ``?name=test``). @@ -43,10 +76,33 @@ def replace_query(self, query: str): return rx.call_script(f"window.history.replaceState({{}}, '', {query!r})") @rx.event - def read_query(self): - """Read the current router query into state vars.""" - self.query_str = self.router.url.query - self.name_param = self.router.url.query_parameters.get("name", "") + def ping(self): + """Send an explicit, non-navigation event to the backend.""" + self.ping_count += 1 + + @rx.event + def do_redirect(self, target: str): + """Navigate to target via rx.redirect (pushes a history entry). + + Args: + target: The path to redirect to. + + Returns: + A redirect event. + """ + return rx.redirect(target) + + @rx.event + def do_redirect_replace(self, target: str): + """Navigate to target via rx.redirect(replace=True). + + Args: + target: The path to redirect to. + + Returns: + A redirect event that replaces the current history entry. + """ + return rx.redirect(target, replace=True) def index(): return rx.box( @@ -56,28 +112,44 @@ def index(): id="token", ), rx.button( - "set ?name=test", - on_click=RouterQueryState.replace_query("?name=test"), + "replaceState ?name=test", + on_click=RouterQueryState.replace_via_script("?name=test"), id="set-name-test", ), + rx.button("ping", on_click=RouterQueryState.ping, id="ping"), + rx.button( + "redirect ?name=one", + on_click=RouterQueryState.do_redirect("/?name=one"), + id="redirect-one", + ), rx.button( - "set ?name=other&page=2", - on_click=RouterQueryState.replace_query("?name=other&page=2"), - id="set-name-other", + "redirect ?name=two", + on_click=RouterQueryState.do_redirect("/?name=two"), + id="redirect-two", ), rx.button( - "read query", - on_click=RouterQueryState.read_query, - id="read-query", + "redirect replace ?name=two", + on_click=RouterQueryState.do_redirect_replace("/?name=two"), + id="redirect-replace-two", ), rx.input(value=RouterQueryState.query_str, read_only=True, id="query-str"), rx.input( value=RouterQueryState.name_param, read_only=True, id="name-param" ), + rx.input( + value=f"{RouterQueryState.load_count}", + read_only=True, + id="load-count", + ), + rx.input( + value=f"{RouterQueryState.ping_count}", + read_only=True, + id="ping-count", + ), ) app = rx.App() - app.add_page(index) + app.add_page(index, route="/", on_load=RouterQueryState.on_load) @pytest.fixture(scope="module") @@ -105,7 +177,7 @@ def router_query_app( def _load(harness: AppHarness, page: Page) -> str: - """Navigate to the app root and wait for hydration. + """Navigate to the app root and wait for hydration and the first on_load. Args: harness: The running AppHarness. @@ -119,30 +191,87 @@ def _load(harness: AppHarness, page: Page) -> str: base = base.rstrip("/") page.goto(base) expect(page.locator("#token")).not_to_have_value("") + # The initial page load fires on_load exactly once. + expect(page.locator("#load-count")).to_have_value("1") return base -def test_replace_state_syncs_router_query(router_query_app: AppHarness, page: Page): - """router.url.query reflects query params set via history.replaceState.""" +def test_replace_state_is_not_reactive_but_next_event_syncs( + router_query_app: AppHarness, page: Page +): + """history.replaceState changes the URL without firing any event; the next + event observes the updated query. + """ _load(router_query_app, page) - - # Baseline: no query yet. - page.click("#read-query") - expect(page.locator("#query-str")).to_have_value("") expect(page.locator("#name-param")).to_have_value("") - # Update the URL query via replaceState (bypasses React Router). + # replaceState updates the browser URL but is not a navigation. page.click("#set-name-test") expect(page).to_have_url(re.compile(r".*\?name=test$")) - # The next event must carry the updated query to the backend. - page.click("#read-query") + # No event was triggered: on_load did not fire and the router did not change. + expect(page.locator("#load-count")).to_have_value("1") + expect(page.locator("#name-param")).to_have_value("") + expect(page.locator("#query-str")).to_have_value("") + + # The next event (a plain, non-navigation event) carries the updated URL, + # so the router-dependent computed vars recompute - without on_load firing. + page.click("#ping") + expect(page.locator("#ping-count")).to_have_value("1") expect(page.locator("#name-param")).to_have_value("test") expect(page.locator("#query-str")).to_have_value("name=test") + expect(page.locator("#load-count")).to_have_value("1") + - # A subsequent replaceState keeps the router in sync. - page.click("#set-name-other") - expect(page).to_have_url(re.compile(r".*\?name=other&page=2$")) - page.click("#read-query") - expect(page.locator("#name-param")).to_have_value("other") - expect(page.locator("#query-str")).to_have_value("name=other&page=2") +def test_redirect_navigates_pushes_and_updates_router_reactively( + router_query_app: AppHarness, page: Page +): + """rx.redirect performs a client-side push navigation, fires on_load, and + updates the router reactively without further interaction. + """ + _load(router_query_app, page) + + # Redirect updates the URL and the router with no extra interaction. + page.click("#redirect-one") + expect(page).to_have_url(re.compile(r".*\?name=one$")) + expect(page.locator("#name-param")).to_have_value("one") + expect(page.locator("#load-count")).to_have_value("2") + + # A second redirect pushes another history entry. + page.click("#redirect-two") + expect(page).to_have_url(re.compile(r".*\?name=two$")) + expect(page.locator("#name-param")).to_have_value("two") + expect(page.locator("#load-count")).to_have_value("3") + + # Because redirect pushes, going back returns to the previous entry. + page.go_back() + expect(page).to_have_url(re.compile(r".*\?name=one$")) + expect(page.locator("#name-param")).to_have_value("one") + expect(page.locator("#load-count")).to_have_value("4") + + +def test_redirect_replace_replaces_history_entry( + router_query_app: AppHarness, page: Page +): + """rx.redirect(replace=True) updates the router reactively and fires + on_load, but replaces the current history entry instead of pushing. + """ + base = _load(router_query_app, page) + + # Push ?name=one, then replace it with ?name=two. + page.click("#redirect-one") + expect(page).to_have_url(re.compile(r".*\?name=one$")) + expect(page.locator("#name-param")).to_have_value("one") + + page.click("#redirect-replace-two") + expect(page).to_have_url(re.compile(r".*\?name=two$")) + expect(page.locator("#name-param")).to_have_value("two") + # replace=True still navigates, so on_load fires (1 initial + 2 redirects). + expect(page.locator("#load-count")).to_have_value("3") + + # The ?name=two entry replaced ?name=one, so going back lands on "/" with no + # query - not on ?name=one (which would indicate a push). + page.go_back() + expect(page).to_have_url(f"{base}/") + expect(page.locator("#name-param")).to_have_value("") + expect(page.locator("#query-str")).to_have_value("")