From ba28a776e34ba75e631fdd1ec863b142e77b00ab Mon Sep 17 00:00:00 2001 From: b84330808 Date: Tue, 20 Oct 2026 22:59:05 +0900 Subject: [PATCH] Decode the pathname in the pages router dcc.Location reports window.location.pathname, which the browser percent-encodes. The pages router matched that raw value, so path variables reached layout() and title() still encoded ("hello%20world") and pages whose path has spaces or non-ASCII characters fell through to the 404 page. The server-side meta tags already use the decoded request.path, so the two disagreed. Decode the pathname before stripping the prefix and matching it. Also let a path variable hold a decoded newline: fnmatch already accepts one, but the regex that extracts the variables did not. Fixes #2360 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + dash/_pages.py | 2 +- dash/dash.py | 10 ++++--- tests/unit/pages/test_pages.py | 48 +++++++++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed4634415..6b810f2b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#4023](https://github.com/plotly/dash/pull/4023) Fix extra callback registrations when an app runs from the parent directory: `python app/app.py`, where the script shares its folder's name, registered its callbacks twice, and `python app/main.py` imported a sibling `app/app.py` as the parent package and registered its callbacks too (regression introduced in 4.4.1 by [#3883](https://github.com/plotly/dash/pull/3883)). Fixes [#4011](https://github.com/plotly/dash/issues/4011). - [#4057](https://github.com/plotly/dash/pull/4057) Fixed background callbacks to update `set_props` instead of replacing. Fixes [#3104](https://github.com/plotly/dash/issues/3104). - [#3923](https://github.com/plotly/dash/pull/3923) Fix a blank page and `SyntaxError` in the browser when a dict component ID has a dot in one of its values. Fixes [#3480](https://github.com/plotly/dash/issues/3480). +- [#2360](https://github.com/plotly/dash/issues/2360) Fix Pages passing percent-encoded path variables to `layout` and `title` (`hello%20world` instead of `hello world`), and showing the 404 page for pages whose `path` has spaces or non-ASCII characters. The pages router now decodes the `pathname` it gets from the browser before matching it, like the server already does for the meta tags. ## [4.4.1] - 2026-07-21 diff --git a/dash/_pages.py b/dash/_pages.py index b63d37c669..793ecaa962 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -142,7 +142,7 @@ def _parse_path_variables(pathname, path_template): var_names = re.findall("<(.*?)>", path_template) # parse variables from path - variables = re.findall(var_pattern, pathname) + variables = re.findall(var_pattern, pathname, re.DOTALL) variables = variables[0] if isinstance(variables[0], tuple) else variables return dict(zip(var_names, variables)) diff --git a/dash/dash.py b/dash/dash.py index 0b37862157..d9149a0a24 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -16,7 +16,7 @@ import mimetypes import hashlib import base64 -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse from typing import ( Any, Callable, @@ -2836,7 +2836,9 @@ async def router_async(): async def update(pathname_, search_, **states): query_parameters = _parse_query_string(search_) page, path_variables = _path_to_page( - self.strip_relative_path(pathname_) + self.strip_relative_path( + unquote(pathname_) if pathname_ else pathname_ + ) ) if page == {}: for module, page in _pages.PAGE_REGISTRY.items(): @@ -2924,7 +2926,9 @@ def router_sync(): def update(pathname_, search_, **states): query_parameters = _parse_query_string(search_) page, path_variables = _path_to_page( - self.strip_relative_path(pathname_) + self.strip_relative_path( + unquote(pathname_) if pathname_ else pathname_ + ) ) if page == {}: for module, page in _pages.PAGE_REGISTRY.items(): diff --git a/tests/unit/pages/test_pages.py b/tests/unit/pages/test_pages.py index df943c8f9b..f06308532c 100644 --- a/tests/unit/pages/test_pages.py +++ b/tests/unit/pages/test_pages.py @@ -2,7 +2,7 @@ import pytest import dash -from dash import Dash, _pages +from dash import Dash, _pages, html from mock import patch @@ -76,3 +76,49 @@ def test_import_layouts_from_pages( page_entry = list(dash.page_registry.values())[0] assert page_entry["module"] == expected_module_name + + +def _route(client, pathname): + response = client.post( + "/_dash-update-component", + json={ + "output": ".._pages_content.children..._pages_store.data..", + "outputs": [ + {"id": "_pages_content", "property": "children"}, + {"id": "_pages_store", "property": "data"}, + ], + "inputs": [ + {"id": "_pages_location", "property": "pathname", "value": pathname}, + {"id": "_pages_location", "property": "search", "value": ""}, + ], + "state": [], + "changedPropIds": ["_pages_location.pathname"], + }, + ) + return response.get_json()["response"]["_pages_content"]["children"] + + +@pytest.mark.parametrize("use_async", [False, True]) +def test_router_decodes_pathname(clear_pages_state, use_async): + if use_async: + pytest.importorskip("asgiref") + app = Dash(__name__, use_pages=True, pages_folder="", use_async=use_async) + dash.register_page( + "report", + path_template="/report/", + layout=lambda name=None, **_: html.Div(name, id="name"), + ) + dash.register_page("cafe", path="/caf\u00e9", layout=html.Div(id="cafe")) + app.layout = html.Div(dash.page_container) + client = app.server.test_client() + client.get("/") + + # dcc.Location sends window.location.pathname, which browsers percent-encode + assert _route(client, "/report/hello%20world")["props"]["children"] == ( + "hello world" + ) + assert _route(client, "/report/%E6%97%A5%E6%9C%AC")["props"]["children"] == ( + "\u65e5\u672c" + ) + assert _route(client, "/report/a%0Ab")["props"]["children"] == "a\nb" + assert _route(client, "/caf%C3%A9")["props"]["id"] == "cafe"