Skip to content
Merged
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 @@ -59,6 +59,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).
- [#4074](https://github.com/plotly/dash/pull/4074) Fix Pages passing percent-encoded path variables to `layout` and `title`, and the 404 page for paths with spaces or non-ASCII characters. Fixes [#2360](https://github.com/plotly/dash/issues/2360).
- [#4012](https://github.com/plotly/dash/pull/4012) Fix `InvalidCallbackReturnValue` raised when a no-output background callback's error handler returns `None` (or the job is cancelled), by treating `None` returns from error handlers and the cancelled-job branch the same way — as `NoUpdate()` only when there are outputs, `None` otherwise. Fixes [#3628](https://github.com/plotly/dash/issues/3628).

## [4.4.1] - 2026-07-21
Expand Down
10 changes: 7 additions & 3 deletions dash/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,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 @@ -2851,7 +2851,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 @@ -2939,7 +2941,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_

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decoding the whole path before matching means an encoded / inside a variable now counts as a real separator. With path_template="/files/<a>/<b>", the URL /files/x/y%2Fz used to give a="x", b="y%2Fz". Now it decodes to /files/x/y/z and gives a="x/y", b="z". The meta tags already behave this way, so I'm fine with it as a follow-up and not a blocker. Can you add this case to the "Behaviour change" note so it's written down?

)
)
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