diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 0889e9359e1..79f6eacf66d 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -159,6 +159,32 @@ def notify_frontend(url: str, backend_present: bool): ) +def _apply_frontend_path(base_url: str, frontend_path: str) -> str: + """Apply ``frontend_path`` to a base URL emitted by the frontend dev server. + + ``urljoin`` treats a relative path differently from an absolute one, so + a ``frontend_path`` configured without a leading slash (e.g. ``"app"``) + used to produce duplicated path segments like + ``http://localhost:3001/app/app/`` (issue #6360). Normalizing the + configured path to ``/path/`` form before joining makes the behavior + consistent regardless of how the user wrote it. + + Args: + base_url: The base URL printed by the frontend dev server. + frontend_path: The configured ``Config.frontend_path``. + + Returns: + The URL with the frontend path correctly applied. + """ + if not frontend_path: + return base_url + stripped = frontend_path.strip("/") + if not stripped: + return base_url + normalized = "/" + stripped + "/" + return urljoin(base_url, normalized) + + def notify_backend(host: str | None = None): """Output a string notifying where the backend is running. @@ -226,9 +252,9 @@ def run_process_and_launch_url( match = re.search(constants.ReactRouter.FRONTEND_LISTENING_REGEX, line) if match: if first_run: - url = match.group(1) - if get_config().frontend_path != "": - url = urljoin(url, get_config().frontend_path) + url = _apply_frontend_path( + match.group(1), get_config().frontend_path + ) notify_frontend(url, backend_present) if backend_present: diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py new file mode 100644 index 00000000000..9c7762a683c --- /dev/null +++ b/tests/units/utils/test_exec.py @@ -0,0 +1,47 @@ +"""Tests for reflex.utils.exec.""" + +from __future__ import annotations + +import pytest + +from reflex.utils.exec import _apply_frontend_path + + +@pytest.mark.parametrize( + ("listening_url", "frontend_path", "expected"), + [ + # Empty frontend_path is a no-op. + ("http://localhost:3001/", "", "http://localhost:3001/"), + # Root path "/" must also be a no-op (would otherwise produce "//", a + # protocol-relative reference that strips the host). + ("http://localhost:3001/", "/", "http://localhost:3001/"), + ("http://localhost:3001/", "//", "http://localhost:3001/"), + # Vite has not yet baked the path into the URL (e.g. prod listening line). + ("http://localhost:3001/", "/app", "http://localhost:3001/app/"), + ("http://localhost:3001/", "app", "http://localhost:3001/app/"), + ("http://localhost:3001/", "app/", "http://localhost:3001/app/"), + ("http://localhost:3001/", "/app/", "http://localhost:3001/app/"), + # Vite already prints the URL with the base appended (dev server). + # Either form of frontend_path must NOT cause the path to be duplicated. + ("http://localhost:3001/noslash/", "noslash", "http://localhost:3001/noslash/"), + ( + "http://localhost:3001/noslash/", + "/noslash", + "http://localhost:3001/noslash/", + ), + # Multi-segment frontend_path. + ( + "http://localhost:3001/", + "app/v1", + "http://localhost:3001/app/v1/", + ), + ( + "http://localhost:3001/app/v1/", + "app/v1", + "http://localhost:3001/app/v1/", + ), + ], +) +def test_apply_frontend_path(listening_url: str, frontend_path: str, expected: str): + """Issue #6360: frontend_path without a leading slash must not duplicate path segments.""" + assert _apply_frontend_path(listening_url, frontend_path) == expected