Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion dash/_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
10 changes: 7 additions & 3 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
48 changes: 47 additions & 1 deletion tests/unit/pages/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest
import dash
from dash import Dash, _pages
from dash import Dash, _pages, html
from mock import patch


Expand Down Expand Up @@ -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/<name>",
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"
Loading