diff --git a/docs/pages/overview.md b/docs/pages/overview.md index 6dc905127b3..02e3e8e1fb8 100644 --- a/docs/pages/overview.md +++ b/docs/pages/overview.md @@ -207,21 +207,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: @@ -229,7 +230,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..fb78fc6047a 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}, + {"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"`). The client-side router sends the current fragment over the WebSocket, so this reflects whatever is shown in the browser URL bar. + +### 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 diff --git a/reflex/istate/data.py b/reflex/istate/data.py index fb3e975bceb..71d93576636 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,184 @@ def __new__(cls, url: str): return obj +@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 type marker for ReflexURL. + + 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. + """ + + +@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 the typed properties below 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 _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. + + Returns: + A Var reading ``name`` from the URL object. + """ + return ObjectItemOperation.create( + self._original.to(ObjectVar, Mapping[str, Any]), + name, + ) + + @property + def scheme(self) -> StringVar: + """The URL scheme (e.g. ``"https"``). + + Returns: + StringVar accessing ``scheme`` on the serialized URL. + """ + return self._component("scheme").to(str) + + @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").to(str) + + @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").to(str) + + @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").to(str) + + @property + def query(self) -> StringVar: + """The raw query string, without a leading ``?``. + + Returns: + StringVar accessing ``query`` on the serialized URL. + """ + return self._component("query").to(str) + + @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("query_parameters").to(ObjectVar, 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").to(str) + + @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.""" @@ -279,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 new file mode 100644 index 00000000000..6ff0b6e805a --- /dev/null +++ b/tests/units/istate/test_data.py @@ -0,0 +1,148 @@ +"""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, ReflexURLCastedVar + +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_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. + """ + 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 + 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", + ): + child = getattr(url_var, component) + 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}"]' + + +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(): + """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. + """ + url_var = rx.State.router.url + assert str(url_var) == f'{url_var._original!s}?.["href"]' diff --git a/tests/units/test_state.py b/tests/units/test_state.py index ed73d2b5b75..8ea94e2274e 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": "",