From 99d326294dd557b6b3db894011bb690710754971 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 17:52:38 +0000 Subject: [PATCH 1/8] docs: document router.url and deprecate router.page Document the router.url attribute (a parsed URL object exposing scheme, netloc, origin, path, query, query_parameters, and fragment) and router.route_id. Add a migration table mapping each deprecated router.page.* attribute to its router.url replacement. Update the "Getting the Current Page" example in pages/overview.md to use the new API. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- docs/pages/overview.md | 17 +++-- docs/utility_methods/router_attributes.md | 93 +++++++++++++++++------ 2 files changed, 79 insertions(+), 31 deletions(-) diff --git a/docs/pages/overview.md b/docs/pages/overview.md index 439151a5f24..b47991a86bf 100644 --- a/docs/pages/overview.md +++ b/docs/pages/overview.md @@ -214,21 +214,22 @@ You can access the current page from the `router` attribute in any state. See th ```python class State(rx.State): def some_method(self): - current_page_route = self.router.page.path - current_page_url = self.router.page.raw_path + current_page_route = self.router.route_id + current_page_url = self.router.url.path # ... Your logic here ... ``` -The `router.page.path` attribute allows you to obtain the path of the current page from the router data, +The `router.route_id` attribute allows you to obtain the route pattern matched for the current page, for [dynamic pages](/docs/pages/dynamic_routing) this will contain the slug rather than the actual value used to load the page. -To get the actual URL displayed in the browser, use `router.page.raw_path`. This -will contain all query parameters and dynamic path segments. +To get the actual URL path displayed in the browser, use `router.url.path`. For +query parameters and the URL fragment, use `router.url.query_parameters` and +`router.url.fragment` respectively. In the above example, `current_page_route` will contain the route pattern (e.g., `/posts/[id]`), while `current_page_url` -will contain the actual URL (e.g., `/posts/123`). +will contain the actual URL path (e.g., `/posts/123`). -To get the full URL, access the same attributes with `full_` prefix. +To get the full URL (scheme, host, path, query and fragment), use `router.url` directly — it is a string subclass containing the complete URL. Example: @@ -236,7 +237,7 @@ Example: class State(rx.State): @rx.var def current_url(self) -> str: - return self.router.page.full_raw_path + return self.router.url def index(): diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index 61af3f96058..f59e0f69119 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -17,24 +17,18 @@ class RouterState(rx.State): router_data = [ - {"name": "rx.State.router.page.host", "value": RouterState.router.page.host}, - {"name": "rx.State.router.page.path", "value": RouterState.router.page.path}, + {"name": "rx.State.router.url", "value": RouterState.router.url.to_string()}, + {"name": "rx.State.router.url.scheme", "value": RouterState.router.url.scheme}, + {"name": "rx.State.router.url.netloc", "value": RouterState.router.url.netloc}, + {"name": "rx.State.router.url.origin", "value": RouterState.router.url.origin}, + {"name": "rx.State.router.url.path", "value": RouterState.router.url.path}, + {"name": "rx.State.router.url.query", "value": RouterState.router.url.query}, { - "name": "rx.State.router.page.raw_path", - "value": RouterState.router.page.raw_path, - }, - { - "name": "rx.State.router.page.full_path", - "value": RouterState.router.page.full_path, - }, - { - "name": "rx.State.router.page.full_raw_path", - "value": RouterState.router.page.full_raw_path, - }, - { - "name": "rx.State.router.page.params", - "value": RouterState.router.page.params.to_string(), + "name": "rx.State.router.url.query_parameters", + "value": RouterState.router.url.query_parameters.to_string(), }, + {"name": "rx.State.router.url.fragment", "value": RouterState.router.url.fragment}, + {"name": "rx.State.router.route_id", "value": RouterState.router.route_id}, { "name": "rx.State.router.session.client_token", "value": RouterState.router.session.client_token, @@ -112,13 +106,8 @@ about the current page, session, or state. The `self.router` attribute has several sub-attributes that provide various information: -- `router.page`: data about the current page and route - - `host`: The hostname and port serving the current page (frontend). - - `path`: The path of the current page (for dynamic pages, this will contain the slug) - - `raw_path`: The path of the page displayed in the browser (including params and dynamic values) - - `full_path`: `path` with `host` prefixed - - `full_raw_path`: `raw_path` with `host` prefixed - - `params`: Dictionary of query params associated with the request +- `router.url`: the URL of the current page, parsed into its components (see [URL Attributes](#url-attributes) below). +- `router.route_id`: the route pattern that matched the current request (e.g. `/posts/[id]`). For [dynamic pages](/docs/pages/dynamic_routing) this contains the slug rather than the actual value used to load the page. - `router.session`: data about the current session - `client_token`: UUID associated with the current tab's token. Each tab has a unique token. @@ -141,6 +130,64 @@ The `self.router` attribute has several sub-attributes that provide various info - `accept_language`: The accepted languages. - `raw_headers`: A mapping of all HTTP headers as a frozen dictionary. This provides access to any header that was sent with the request, not just the common ones listed above. +## URL Attributes + +`self.router.url` is the full URL of the page currently displayed in the browser, parsed into its components using Python's standard `urllib.parse.urlsplit`. It is a string subclass, so it can be used anywhere a string is expected (for example, passed to `rx.text(self.router.url)` to render the whole URL), and additionally exposes the following attributes: + +- `scheme`: The URL scheme (e.g. `"http"` or `"https"`). +- `netloc`: The network location, including hostname and optional port (e.g. `"example.com:3000"`). +- `origin`: The scheme and netloc joined together (e.g. `"https://example.com:3000"`). Equivalent to `f"{scheme}://{netloc}"`. +- `path`: The URL path as displayed in the browser, including any filled-in dynamic segments but excluding the query string and fragment (e.g. `"/posts/123"`). +- `query`: The raw query string, without the leading `?` (e.g. `"tab=comments&sort=new"`). +- `query_parameters`: The query string parsed into a frozen, immutable `Mapping[str, str]`. Use this instead of parsing `query` by hand. +- `fragment`: The URL fragment, without the leading `#` (e.g. `"section-2"`). Browsers do not send the fragment to the server in HTTP requests, so this is usually empty unless the URL was constructed with one explicitly. + +### Example + +For a request to `https://example.com:3000/posts/123?tab=comments#top` matching the route `/posts/[id]`: + +| Attribute | Value | +| :----------------------------------- | :----------------------------------------------------- | +| `self.router.url` | `"https://example.com:3000/posts/123?tab=comments#top"`| +| `self.router.url.scheme` | `"https"` | +| `self.router.url.netloc` | `"example.com:3000"` | +| `self.router.url.origin` | `"https://example.com:3000"` | +| `self.router.url.path` | `"/posts/123"` | +| `self.router.url.query` | `"tab=comments"` | +| `self.router.url.query_parameters` | `{"tab": "comments"}` | +| `self.router.url.fragment` | `"top"` | +| `self.router.route_id` | `"/posts/[id]"` | + +### Reading Query Parameters + +`query_parameters` is the preferred way to read values from the query string. It is a frozen mapping (immutable and hashable), so it is safe to use inside `@rx.var` computed vars and event handlers: + +```python +class State(rx.State): + @rx.var + def selected_tab(self) -> str: + return self.router.url.query_parameters.get("tab", "overview") + + def on_load(self): + page = self.router.url.query_parameters.get("page", "1") + # ... load the appropriate data for that page ... +``` + +For dynamic path segments such as `[id]` or `[[...splat]]`, see [Dynamic Routes](/docs/pages/dynamic_routing) — those values are exposed as state vars on the root state (e.g. `rx.State.id`, `rx.State.splat`), not through `router.url`. + +## Migrating from `router.page` + +The `self.router.page` namespace is deprecated as of Reflex 0.8.1 and will be removed in 1.0. Its functionality is now provided by `self.router.url` together with `self.router.route_id`. Use the table below to update existing code: + +| Deprecated | Replacement | Notes | +| :---------------------------------- | :------------------------------------------------------------ | :-------------------------------------------------------------------- | +| `self.router.page.path` | `self.router.route_id` | The route pattern, e.g. `/posts/[id]`. | +| `self.router.page.raw_path` | `self.router.url.path` | The actual path in the browser. Append `?{url.query}` if you also need query params. | +| `self.router.page.full_path` | `f"{self.router.url.origin}{self.router.route_id}"` | Origin prefixed onto the route pattern. Rarely needed. | +| `self.router.page.full_raw_path` | `self.router.url` | `router.url` is itself the full URL as a string. | +| `self.router.page.host` | `self.router.url.origin` | Full origin including scheme (e.g. `"http://localhost:3000"`). Use `self.router.url.netloc` for just `host:port`. | +| `self.router.page.params` | `self.router.url.query_parameters` | Now a frozen mapping rather than a plain dict. | + ### Example Values on this Page ```python eval From 20d60e99788f272c4d40bc7584c946c0dc6aa94c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 18:09:16 +0000 Subject: [PATCH 2/8] docs: correct fragment note in router.url docs The previous wording claimed the fragment is "usually empty" because browsers do not send it over HTTP. That is wrong for Reflex: the client template builds asPath as pathname + search + hash and ships it over the WebSocket, so router.url.fragment does reflect the current URL bar. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- docs/utility_methods/router_attributes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index f59e0f69119..9d09a7a322f 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -140,7 +140,7 @@ The `self.router` attribute has several sub-attributes that provide various info - `path`: The URL path as displayed in the browser, including any filled-in dynamic segments but excluding the query string and fragment (e.g. `"/posts/123"`). - `query`: The raw query string, without the leading `?` (e.g. `"tab=comments&sort=new"`). - `query_parameters`: The query string parsed into a frozen, immutable `Mapping[str, str]`. Use this instead of parsing `query` by hand. -- `fragment`: The URL fragment, without the leading `#` (e.g. `"section-2"`). Browsers do not send the fragment to the server in HTTP requests, so this is usually empty unless the URL was constructed with one explicitly. +- `fragment`: The URL fragment, without the leading `#` (e.g. `"section-2"`). The client-side router sends the current fragment over the WebSocket, so this reflects whatever is shown in the browser URL bar. ### Example From 48c591ac1877b6782ad94d9b82369cce0a9ebe04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 19:43:08 +0000 Subject: [PATCH 3/8] fix(state): support attribute access on router.url Var Accessing rx.State.router.url.scheme (and the other parsed URL components) previously raised VarAttributeError because ReflexURL is a str subclass, so guess_type mapped it to StringVar, which rejects arbitrary attribute lookup. Introduce ReflexURLVar, a StringVar subclass that exposes each URL component (scheme/netloc/origin/path/query/query_parameters/fragment/ href) as a typed child Var via __getattr__. The companion ReflexURLCastedVar overrides the rendered JS expression so the Var itself resolves to the href property at compile time, meaning rx.text(self.router.url) renders the full URL string while self.router.url.scheme, .path, etc. resolve to the corresponding keys on the serialized object. Add a dict serializer for ReflexURL so the frontend receives {scheme, netloc, origin, path, query, query_parameters, fragment, href} under router.url. Drop the now-unnecessary .to_string() call in the docs sample table since the Var already renders as a URL string. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- docs/utility_methods/router_attributes.md | 2 +- reflex/istate/data.py | 175 +++++++++++++++++++++- tests/units/istate/test_data.py | 78 ++++++++++ 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 tests/units/istate/test_data.py diff --git a/docs/utility_methods/router_attributes.md b/docs/utility_methods/router_attributes.md index 9d09a7a322f..fb78fc6047a 100644 --- a/docs/utility_methods/router_attributes.md +++ b/docs/utility_methods/router_attributes.md @@ -17,7 +17,7 @@ class RouterState(rx.State): router_data = [ - {"name": "rx.State.router.url", "value": RouterState.router.url.to_string()}, + {"name": "rx.State.router.url", "value": RouterState.router.url}, {"name": "rx.State.router.url.scheme", "value": RouterState.router.url.scheme}, {"name": "rx.State.router.url.netloc", "value": RouterState.router.url.netloc}, {"name": "rx.State.router.url.origin", "value": RouterState.router.url.origin}, diff --git a/reflex/istate/data.py b/reflex/istate/data.py index fb3e975bceb..eb62e157d35 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -3,12 +3,22 @@ import dataclasses from collections.abc import Mapping from types import MappingProxyType -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, ClassVar from urllib.parse import _NetlocResultMixinStr, parse_qsl, urlsplit from reflex_base import constants from reflex_base.utils import console, format from reflex_base.utils.serializers import serializer +from reflex_base.vars.base import ( + CachedVarOperation, + Var, + VarData, + VarSubclassEntry, + _var_subclasses, + cached_property_no_lock, +) +from reflex_base.vars.object import ObjectItemOperation, ObjectVar +from reflex_base.vars.sequence import StringVar @dataclasses.dataclass(frozen=True, init=False) @@ -149,6 +159,169 @@ def __new__(cls, url: str): return obj +# Keys exposed on the serialized ReflexURL payload and accessible as attributes +# on ReflexURLVar. Values are the Python type of each key (used to pick the +# right Var subclass via guess_type). +_REFLEX_URL_COMPONENT_TYPES: dict[str, Any] = { + "scheme": str, + "netloc": str, + "origin": str, + "path": str, + "query": str, + "query_parameters": Mapping[str, str], + "fragment": str, + "href": str, +} + + +@serializer(to=dict) +def _serialize_reflex_url(obj: ReflexURL) -> dict: + """Serialize a ReflexURL to an object with its parsed components. + + The full URL is exposed under the ``href`` key so the frontend can read + either the whole URL or any individual component without re-parsing. + + Args: + obj: the ReflexURL to serialize. + + Returns: + A dict with scheme, netloc, origin, path, query, query_parameters, + fragment, and href. + """ + return { + "scheme": obj.scheme, + "netloc": obj.netloc, + "origin": obj.origin, + "path": obj.path, + "query": obj.query, + "query_parameters": dict(obj.query_parameters), + "fragment": obj.fragment, + "href": str.__str__(obj), + } + + +class ReflexURLVar(StringVar[ReflexURL]): + """Var for ReflexURL that exposes URL components as child Vars. + + Accessing ``scheme``/``netloc``/``origin``/``path``/``query``/ + ``query_parameters``/``fragment``/``href`` returns a typed child Var that + reads the corresponding key on the serialized URL object. Using the Var + itself as a string (e.g. ``rx.text(State.router.url)``) emits JS that + reads the ``href`` key so the full URL string is rendered. + """ + + def __getattr__(self, name: str) -> Var: + """Get a URL component Var by attribute name. + + Args: + name: The component name. + + Returns: + A child Var for the component. + """ + component_type = _REFLEX_URL_COMPONENT_TYPES.get(name) + if component_type is not None: + return ObjectItemOperation.create( + _reflex_url_as_object(self), + name, + component_type, + ).guess_type() + return super().__getattr__(name) # pyright: ignore[reportAttributeAccessIssue] + + +def _reflex_url_as_object(url_var: Var) -> ObjectVar: + """View a ReflexURL-typed Var as an object for key access. + + Args: + url_var: The ReflexURL-typed Var. + + Returns: + An ObjectVar wrapping the same JS expression. + """ + return url_var.to(ObjectVar, Mapping[str, Any]) + + +@dataclasses.dataclass( + eq=False, + frozen=True, + slots=True, +) +class ReflexURLCastedVar(CachedVarOperation, ReflexURLVar): + """Cast-to-ReflexURL operation whose default rendering reads ``href``. + + Constructed when ``guess_type`` / ``to(ReflexURLVar)`` is invoked on any + Var typed as ReflexURL (e.g. ``State.router.url``). Its top-level JS + expression is ``{original}?.["href"]`` so string-context usage produces + the full URL; component access (via ``__getattr__`` inherited from + ``ReflexURLVar``) reads the matching key on ``{original}`` instead. + """ + + _original: Var = dataclasses.field( + default_factory=lambda: Var(_js_expr="null", _var_type=None), + ) + _default_var_type: ClassVar[Any] = ReflexURL + + @cached_property_no_lock + def _cached_var_name(self) -> str: + """Render the URL as its ``href`` string in JS. + + Returns: + The JS expression for the full URL string. + """ + return f'{self._original!s}?.["href"]' + + def __getattr__(self, name: str) -> Any: + """Dispatch URL component access to the original object. + + Args: + name: The attribute name. + + Returns: + The child Var for the URL component, or the inherited attribute. + """ + component_type = _REFLEX_URL_COMPONENT_TYPES.get(name) + if component_type is not None: + return ObjectItemOperation.create( + _reflex_url_as_object(self._original), + name, + component_type, + ).guess_type() + return CachedVarOperation.__getattr__(self, name) + + @classmethod + def create( + cls, + value: Var, + _var_type: Any = None, + _var_data: VarData | None = None, + ) -> "ReflexURLCastedVar": + """Create a ReflexURLCastedVar wrapping another Var. + + Args: + value: The Var being cast to ReflexURL. + _var_type: Optional override for the var type. + _var_data: Additional VarData to merge in. + + Returns: + The new ReflexURLCastedVar. + """ + return cls( + _js_expr="", + _var_type=_var_type or ReflexURL, + _var_data=_var_data, + _original=value, + ) + + +# ReflexURLCastedVar intentionally uses the CachedVarOperation lineage rather +# than ToOperation so _js_expr can render as {original}?.["href"]. The registry +# entry still accepts it because .to()/guess_type() only call .create(...), +# which has a compatible signature. +_var_subclasses.append( + VarSubclassEntry(ReflexURLVar, ReflexURLCastedVar, (ReflexURL,)) # pyright: ignore[reportArgumentType] +) + + @dataclasses.dataclass(frozen=True) class PageData: """An object containing page data.""" diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py new file mode 100644 index 00000000000..e7a411c9448 --- /dev/null +++ b/tests/units/istate/test_data.py @@ -0,0 +1,78 @@ +"""Tests for ReflexURL parsing, serialization, and Var attribute access.""" + +from urllib.parse import parse_qsl + +import reflex as rx +from reflex.istate.data import ReflexURL + +SAMPLE_URL = "https://example.com:3000/posts/123?tab=comments&sort=new#top" + + +def test_reflex_url_parses_components(): + url = ReflexURL(SAMPLE_URL) + assert str(url) == SAMPLE_URL + assert url.scheme == "https" + assert url.netloc == "example.com:3000" + assert url.origin == "https://example.com:3000" + assert url.path == "/posts/123" + assert url.query == "tab=comments&sort=new" + assert dict(url.query_parameters) == dict(parse_qsl("tab=comments&sort=new")) + assert url.fragment == "top" + + +def test_reflex_url_serializes_with_all_components(): + """ReflexURL should serialize to an object with href + parsed components + so the frontend can read any sub-field without re-parsing. + """ + from reflex_base.utils.serializers import serialize + + url = ReflexURL(SAMPLE_URL) + payload = serialize(url) + + assert isinstance(payload, dict) + assert payload["href"] == SAMPLE_URL + assert payload["scheme"] == "https" + assert payload["netloc"] == "example.com:3000" + assert payload["origin"] == "https://example.com:3000" + assert payload["path"] == "/posts/123" + assert payload["query"] == "tab=comments&sort=new" + assert payload["query_parameters"] == dict(parse_qsl("tab=comments&sort=new")) + assert payload["fragment"] == "top" + + +def test_router_url_var_supports_component_access(): + """rx.State.router.url. should produce a Var whose JS expression + accesses the corresponding key on the serialized URL object. Regression + test for VarAttributeError: StringVar has no attribute 'scheme'. + """ + + class _State(rx.State): + pass + + url_var = _State.router.url + + for component in ("scheme", "netloc", "origin", "path", "query", "fragment"): + child = getattr(url_var, component) + child_js = str(child) + assert f'"{component}"' in child_js, ( + f"expected child Var for {component!r} to reference key " + f"{component!r} in JS, got {child_js!r}" + ) + + # query_parameters is a mapping; attribute access must still succeed. + assert '"query_parameters"' in str(url_var.query_parameters) + + +def test_router_url_var_renders_as_href_at_top_level(): + """When used as a string (e.g. in rx.text), rx.State.router.url should + emit JS that resolves to the full URL string by reading the 'href' + property on the serialized object. + """ + + class _State(rx.State): + pass + + url_js = str(_State.router.url) + assert '"href"' in url_js, ( + f"expected top-level router.url to resolve to .href in JS, got {url_js!r}" + ) From 9f5d888d2801fa735199aeef47769833e859185c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 23:31:25 +0000 Subject: [PATCH 4/8] refactor(state): expose router.url components as typed properties Address review: replace the __getattr__ dispatch + component-type dict with one @property per URL component on ReflexURLCastedVar, each annotated with the correct Var return type. This gives static typing for self.router.url.scheme / .path / .query_parameters / etc. and removes the dead __getattr__ on ReflexURLVar (all instances flow through ReflexURLCastedVar). Also tighten the regression tests: drop the unnecessary local _State class in favor of rx.State directly, and assert on both the emitted JS expression and the returned Var's _var_type so future regressions on the component typing are caught. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- reflex/istate/data.py | 155 ++++++++++++++++++-------------- tests/units/istate/test_data.py | 67 +++++++++----- 2 files changed, 133 insertions(+), 89 deletions(-) diff --git a/reflex/istate/data.py b/reflex/istate/data.py index eb62e157d35..74487227c3a 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -159,21 +159,6 @@ def __new__(cls, url: str): return obj -# Keys exposed on the serialized ReflexURL payload and accessible as attributes -# on ReflexURLVar. Values are the Python type of each key (used to pick the -# right Var subclass via guess_type). -_REFLEX_URL_COMPONENT_TYPES: dict[str, Any] = { - "scheme": str, - "netloc": str, - "origin": str, - "path": str, - "query": str, - "query_parameters": Mapping[str, str], - "fragment": str, - "href": str, -} - - @serializer(to=dict) def _serialize_reflex_url(obj: ReflexURL) -> dict: """Serialize a ReflexURL to an object with its parsed components. @@ -201,44 +186,12 @@ def _serialize_reflex_url(obj: ReflexURL) -> dict: class ReflexURLVar(StringVar[ReflexURL]): - """Var for ReflexURL that exposes URL components as child Vars. - - Accessing ``scheme``/``netloc``/``origin``/``path``/``query``/ - ``query_parameters``/``fragment``/``href`` returns a typed child Var that - reads the corresponding key on the serialized URL object. Using the Var - itself as a string (e.g. ``rx.text(State.router.url)``) emits JS that - reads the ``href`` key so the full URL string is rendered. - """ - - def __getattr__(self, name: str) -> Var: - """Get a URL component Var by attribute name. - - Args: - name: The component name. - - Returns: - A child Var for the component. - """ - component_type = _REFLEX_URL_COMPONENT_TYPES.get(name) - if component_type is not None: - return ObjectItemOperation.create( - _reflex_url_as_object(self), - name, - component_type, - ).guess_type() - return super().__getattr__(name) # pyright: ignore[reportAttributeAccessIssue] - - -def _reflex_url_as_object(url_var: Var) -> ObjectVar: - """View a ReflexURL-typed Var as an object for key access. - - Args: - url_var: The ReflexURL-typed Var. + """Var type marker for ReflexURL. - Returns: - An ObjectVar wrapping the same JS expression. + Exists only to anchor the type registration for ``ReflexURL``; actual + instances returned by the Var system are always ``ReflexURLCastedVar``, + which exposes URL components as typed properties. """ - return url_var.to(ObjectVar, Mapping[str, Any]) @dataclasses.dataclass( @@ -252,8 +205,8 @@ class ReflexURLCastedVar(CachedVarOperation, ReflexURLVar): Constructed when ``guess_type`` / ``to(ReflexURLVar)`` is invoked on any Var typed as ReflexURL (e.g. ``State.router.url``). Its top-level JS expression is ``{original}?.["href"]`` so string-context usage produces - the full URL; component access (via ``__getattr__`` inherited from - ``ReflexURLVar``) reads the matching key on ``{original}`` instead. + the full URL; component access via the typed properties below reads the + matching key on ``{original}`` instead. """ _original: Var = dataclasses.field( @@ -270,23 +223,95 @@ def _cached_var_name(self) -> str: """ return f'{self._original!s}?.["href"]' - def __getattr__(self, name: str) -> Any: - """Dispatch URL component access to the original object. + def _component(self, name: str, var_type: Any) -> Var: + """Build a child Var that reads ``name`` on the serialized URL object. Args: - name: The attribute name. + name: The component key on the serialized URL object. + var_type: The python type of the component value. + + Returns: + The child Var, already guess_type'd to the appropriate subclass. + """ + return ObjectItemOperation.create( + self._original.to(ObjectVar, Mapping[str, Any]), + name, + var_type, + ).guess_type() + + @property + def scheme(self) -> StringVar: + """The URL scheme (e.g. ``"https"``). + + Returns: + StringVar accessing ``scheme`` on the serialized URL. + """ + return self._component("scheme", str) # pyright: ignore[reportReturnType] + + @property + def netloc(self) -> StringVar: + """The network location, including host and optional port. + + Returns: + StringVar accessing ``netloc`` on the serialized URL. + """ + return self._component("netloc", str) # pyright: ignore[reportReturnType] + + @property + def origin(self) -> StringVar: + """The scheme and netloc joined (e.g. ``"https://example.com:3000"``). + + Returns: + StringVar accessing ``origin`` on the serialized URL. + """ + return self._component("origin", str) # pyright: ignore[reportReturnType] + + @property + def path(self) -> StringVar: + """The URL path as shown in the browser (no query or fragment). + + Returns: + StringVar accessing ``path`` on the serialized URL. + """ + return self._component("path", str) # pyright: ignore[reportReturnType] + + @property + def query(self) -> StringVar: + """The raw query string, without a leading ``?``. + + Returns: + StringVar accessing ``query`` on the serialized URL. + """ + return self._component("query", str) # pyright: ignore[reportReturnType] + + @property + def query_parameters(self) -> ObjectVar[Mapping[str, str]]: + """The parsed query string as a mapping. + + Returns: + ObjectVar accessing ``query_parameters`` on the serialized URL. + """ + return self._component( # pyright: ignore[reportReturnType] + "query_parameters", Mapping[str, str] + ) + + @property + def fragment(self) -> StringVar: + """The URL fragment, without a leading ``#``. + + Returns: + StringVar accessing ``fragment`` on the serialized URL. + """ + return self._component("fragment", str) # pyright: ignore[reportReturnType] + + @property + def href(self) -> StringVar: + """The full URL string. Returns: - The child Var for the URL component, or the inherited attribute. + StringVar accessing ``href`` on the serialized URL. """ - component_type = _REFLEX_URL_COMPONENT_TYPES.get(name) - if component_type is not None: - return ObjectItemOperation.create( - _reflex_url_as_object(self._original), - name, - component_type, - ).guess_type() - return CachedVarOperation.__getattr__(self, name) + return self._component("href", str) # pyright: ignore[reportReturnType] @classmethod def create( diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index e7a411c9448..86f0f433b58 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -1,9 +1,13 @@ """Tests for ReflexURL parsing, serialization, and Var attribute access.""" +from collections.abc import Mapping from urllib.parse import parse_qsl +from reflex_base.vars.object import ObjectVar +from reflex_base.vars.sequence import StringVar + import reflex as rx -from reflex.istate.data import ReflexURL +from reflex.istate.data import ReflexURL, ReflexURLCastedVar SAMPLE_URL = "https://example.com:3000/posts/123?tab=comments&sort=new#top" @@ -40,27 +44,48 @@ def test_reflex_url_serializes_with_all_components(): assert payload["fragment"] == "top" -def test_router_url_var_supports_component_access(): - """rx.State.router.url. should produce a Var whose JS expression - accesses the corresponding key on the serialized URL object. Regression - test for VarAttributeError: StringVar has no attribute 'scheme'. +def test_router_url_var_is_casted(): + """rx.State.router.url should be wrapped in a ReflexURLCastedVar so the + URL component properties resolve correctly. """ + assert isinstance(rx.State.router.url, ReflexURLCastedVar) - class _State(rx.State): - pass - - url_var = _State.router.url - for component in ("scheme", "netloc", "origin", "path", "query", "fragment"): +def test_router_url_var_string_components(): + """Each string component of router.url should render as a bracket-key on + the router.url object and produce a StringVar typed as str. Regression + test for VarAttributeError: StringVar has no attribute 'scheme'. + """ + url_var = rx.State.router.url + base = str(url_var._original) + + for component in ( + "scheme", + "netloc", + "origin", + "path", + "query", + "fragment", + "href", + ): child = getattr(url_var, component) - child_js = str(child) - assert f'"{component}"' in child_js, ( - f"expected child Var for {component!r} to reference key " - f"{component!r} in JS, got {child_js!r}" + assert isinstance(child, StringVar), ( + f"{component!r} should be a StringVar, got {type(child).__name__}" ) + assert child._var_type is str + assert str(child) == f'{base}?.["{component}"]' - # query_parameters is a mapping; attribute access must still succeed. - assert '"query_parameters"' in str(url_var.query_parameters) + +def test_router_url_var_query_parameters_is_object(): + """query_parameters should be an ObjectVar over Mapping[str, str] so + indexing and iteration produce correctly typed child Vars. + """ + url_var = rx.State.router.url + qp = url_var.query_parameters + + assert isinstance(qp, ObjectVar) + assert qp._var_type == Mapping[str, str] + assert str(qp) == f'{url_var._original!s}?.["query_parameters"]' def test_router_url_var_renders_as_href_at_top_level(): @@ -68,11 +93,5 @@ def test_router_url_var_renders_as_href_at_top_level(): emit JS that resolves to the full URL string by reading the 'href' property on the serialized object. """ - - class _State(rx.State): - pass - - url_js = str(_State.router.url) - assert '"href"' in url_js, ( - f"expected top-level router.url to resolve to .href in JS, got {url_js!r}" - ) + url_var = rx.State.router.url + assert str(url_var) == f'{url_var._original!s}?.["href"]' From 14c8da41307dc7ec8b2ed5e3873be0698056dbe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 00:05:48 +0000 Subject: [PATCH 5/8] refactor: drop reportReturnType ignores on router.url properties Address review: move .guess_type() out of _component and have each property narrow the raw ObjectItemOperation with .to(str) or .to(ObjectVar, Mapping[str, str]) instead. The .to() overloads carry accurate static return types, so StringVar / ObjectVar annotations type-check without any pyright: ignore[reportReturnType] suppressions. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- reflex/istate/data.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/reflex/istate/data.py b/reflex/istate/data.py index 74487227c3a..d25b3e97122 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -223,21 +223,22 @@ def _cached_var_name(self) -> str: """ return f'{self._original!s}?.["href"]' - def _component(self, name: str, var_type: Any) -> Var: - """Build a child Var that reads ``name`` on the serialized URL object. + def _component(self, name: str) -> Var: + """Build an indexing operation for a key on the serialized URL object. + + The returned Var is untyped; each property wraps this in a + ``.to(...)`` call to narrow it to the correct Var subclass. Args: name: The component key on the serialized URL object. - var_type: The python type of the component value. Returns: - The child Var, already guess_type'd to the appropriate subclass. + A Var reading ``name`` from the URL object. """ return ObjectItemOperation.create( self._original.to(ObjectVar, Mapping[str, Any]), name, - var_type, - ).guess_type() + ) @property def scheme(self) -> StringVar: @@ -246,7 +247,7 @@ def scheme(self) -> StringVar: Returns: StringVar accessing ``scheme`` on the serialized URL. """ - return self._component("scheme", str) # pyright: ignore[reportReturnType] + return self._component("scheme").to(str) @property def netloc(self) -> StringVar: @@ -255,7 +256,7 @@ def netloc(self) -> StringVar: Returns: StringVar accessing ``netloc`` on the serialized URL. """ - return self._component("netloc", str) # pyright: ignore[reportReturnType] + return self._component("netloc").to(str) @property def origin(self) -> StringVar: @@ -264,7 +265,7 @@ def origin(self) -> StringVar: Returns: StringVar accessing ``origin`` on the serialized URL. """ - return self._component("origin", str) # pyright: ignore[reportReturnType] + return self._component("origin").to(str) @property def path(self) -> StringVar: @@ -273,7 +274,7 @@ def path(self) -> StringVar: Returns: StringVar accessing ``path`` on the serialized URL. """ - return self._component("path", str) # pyright: ignore[reportReturnType] + return self._component("path").to(str) @property def query(self) -> StringVar: @@ -282,7 +283,7 @@ def query(self) -> StringVar: Returns: StringVar accessing ``query`` on the serialized URL. """ - return self._component("query", str) # pyright: ignore[reportReturnType] + return self._component("query").to(str) @property def query_parameters(self) -> ObjectVar[Mapping[str, str]]: @@ -291,9 +292,7 @@ def query_parameters(self) -> ObjectVar[Mapping[str, str]]: Returns: ObjectVar accessing ``query_parameters`` on the serialized URL. """ - return self._component( # pyright: ignore[reportReturnType] - "query_parameters", Mapping[str, str] - ) + return self._component("query_parameters").to(ObjectVar, Mapping[str, str]) @property def fragment(self) -> StringVar: @@ -302,7 +301,7 @@ def fragment(self) -> StringVar: Returns: StringVar accessing ``fragment`` on the serialized URL. """ - return self._component("fragment", str) # pyright: ignore[reportReturnType] + return self._component("fragment").to(str) @property def href(self) -> StringVar: @@ -311,7 +310,7 @@ def href(self) -> StringVar: Returns: StringVar accessing ``href`` on the serialized URL. """ - return self._component("href", str) # pyright: ignore[reportReturnType] + return self._component("href").to(str) @classmethod def create( From d9df26e1aad3fd14dcbea5ef0a838cef1323d7d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 00:10:24 +0000 Subject: [PATCH 6/8] refactor: drop href property from router.url Var Address review: ReflexURL has no .href attribute in Python, so exposing one as a Var property creates a Var-only API that doesn't exist at runtime. Drop the property. The serialized payload still carries an href key because _cached_var_name needs it to render the top-level Var as the URL string in JS, but that's an internal implementation detail and is not exposed on the Python API. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- reflex/istate/data.py | 9 --------- tests/units/istate/test_data.py | 1 - 2 files changed, 10 deletions(-) diff --git a/reflex/istate/data.py b/reflex/istate/data.py index d25b3e97122..4734bbc7e2e 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -303,15 +303,6 @@ def fragment(self) -> StringVar: """ return self._component("fragment").to(str) - @property - def href(self) -> StringVar: - """The full URL string. - - Returns: - StringVar accessing ``href`` on the serialized URL. - """ - return self._component("href").to(str) - @classmethod def create( cls, diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index 86f0f433b58..264a3966112 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -66,7 +66,6 @@ def test_router_url_var_string_components(): "path", "query", "fragment", - "href", ): child = getattr(url_var, component) assert isinstance(child, StringVar), ( From 70294a72852a8a4aecb32126e5e96d61fca7208b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 00:22:28 +0000 Subject: [PATCH 7/8] test: assert router.url Var propagates VarData from original Address review: add coverage for the VarData propagation path. The casted Var and every child component Var must carry the state-context imports and hook from the underlying router state-var access, otherwise using self.router.url.scheme in a component would silently drop the state subscription at compile time. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- tests/units/istate/test_data.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index 264a3966112..39b090ac753 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -51,6 +51,25 @@ def test_router_url_var_is_casted(): assert isinstance(rx.State.router.url, ReflexURLCastedVar) +def test_router_url_var_propagates_var_data(): + """The casted URL Var (and the child component Vars it produces) must + carry the same VarData as the underlying state-var access, so the + compiler still emits the state-context imports and hook needed to read + ``router`` on the frontend. + """ + url_var = rx.State.router.url + original_data = url_var._original._get_all_var_data() + assert original_data is not None + # The state import/hook needed to resolve `router` must flow through the + # ReflexURLCastedVar wrapper... + assert url_var._get_all_var_data() == original_data + # ...and through every child component Var (otherwise using + # self.router.url.scheme in a component would silently drop the state + # subscription). + assert url_var.scheme._get_all_var_data() == original_data + assert url_var.query_parameters._get_all_var_data() == original_data + + def test_router_url_var_string_components(): """Each string component of router.url should render as a bracket-key on the router.url object and produce a StringVar typed as str. Regression From a1ed88bd273ca8d20ec01e23fa4f54729f7c965c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 00:44:27 +0000 Subject: [PATCH 8/8] fix(state): explicitly serialize ReflexURL inside RouterData ReflexURL is a str subclass, so json.dumps handles it natively and never invokes the default=serialize hook registered by the framework. This meant the enclosing RouterData serializer handed the raw ReflexURL to json.dumps, which short-circuited it to a plain URL string instead of the {scheme, netloc, ..., href} component dict the frontend expects for router.url. access. Serialize the url field eagerly inside serialize_router_data so the component dict makes it through to the frontend. Add a regression test that goes through the real json_dumps path (not just serialize) to catch this short-circuit in the future, and update the fixture formatted_router in test_state.py and test_format.py to match the new payload shape. https://claude.ai/code/session_01GTk6Ni7kyfd8VQKtq7JAXh --- reflex/istate/data.py | 6 +++++- tests/units/istate/test_data.py | 33 ++++++++++++++++++++++++++++++++ tests/units/test_state.py | 11 ++++++++++- tests/units/utils/test_format.py | 11 ++++++++++- 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/reflex/istate/data.py b/reflex/istate/data.py index 4734bbc7e2e..71d93576636 100644 --- a/reflex/istate/data.py +++ b/reflex/istate/data.py @@ -467,6 +467,10 @@ def serialize_router_data(obj: RouterData) -> dict: "session": obj.session, "headers": obj.headers, "page": obj._page, - "url": obj.url, + # ReflexURL is a str subclass, so json.dumps handles it natively and + # never invokes the `default=serialize` hook. Call the URL serializer + # eagerly here so the frontend receives the parsed component dict + # instead of just the raw URL string. + "url": _serialize_reflex_url(obj.url), "route_id": obj.route_id, } diff --git a/tests/units/istate/test_data.py b/tests/units/istate/test_data.py index 39b090ac753..6ff0b6e805a 100644 --- a/tests/units/istate/test_data.py +++ b/tests/units/istate/test_data.py @@ -44,6 +44,39 @@ def test_reflex_url_serializes_with_all_components(): assert payload["fragment"] == "top" +def test_reflex_url_serializes_when_nested_in_router_data(): + """When a RouterData is serialized (the normal state-sync path), the + ``url`` field must come out as a full component dict rather than being + short-circuited to a plain JSON string by json.dumps. Because ReflexURL + is a ``str`` subclass, json.dumps handles it natively and never invokes + the ``default=serialize`` hook, so the enclosing serializer has to + serialize it explicitly. + """ + import json + + from reflex_base import constants + from reflex_base.utils.format import json_dumps + + from reflex.istate.data import RouterData + + rd = RouterData.from_router_data({ + constants.RouteVar.HEADERS: {"origin": "https://example.com:3000"}, + constants.RouteVar.PATH: "/posts/[id]", + constants.RouteVar.ORIGIN: "/posts/123?tab=comments&sort=new#top", + }) + payload = json.loads(json_dumps(rd)) + + assert isinstance(payload["url"], dict), ( + f"expected url to serialize to a component dict, got {payload['url']!r}" + ) + assert payload["url"]["href"] == SAMPLE_URL + assert payload["url"]["scheme"] == "https" + assert payload["url"]["path"] == "/posts/123" + assert payload["url"]["query_parameters"] == dict( + parse_qsl("tab=comments&sort=new") + ) + + def test_router_url_var_is_casted(): """rx.State.router.url should be wrapped in a ReflexURLCastedVar so the URL component properties resolve correctly. diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 0106beebc25..808f4fdb4a8 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -80,7 +80,16 @@ formatted_router = { "route_id": "", - "url": "", + "url": { + "scheme": "", + "netloc": "", + "origin": "://", + "path": "", + "query": "", + "query_parameters": {}, + "fragment": "", + "href": "", + }, "session": {"client_token": "", "client_ip": "", "session_id": ""}, "headers": { "host": "", diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index 6d60c2f2109..c7ae6397020 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -659,7 +659,16 @@ def test_format_query_params(input, output): formatted_router = { "route_id": "", - "url": "", + "url": { + "scheme": "", + "netloc": "", + "origin": "://", + "path": "", + "query": "", + "query_parameters": {}, + "fragment": "", + "href": "", + }, "session": {"client_token": "", "client_ip": "", "session_id": ""}, "headers": { "host": "",