From 139ba3c7b4776ba88047225b5673cd54eb37734b Mon Sep 17 00:00:00 2001 From: FarhanAliRaza Date: Fri, 28 Aug 2026 20:49:30 +0000 Subject: [PATCH 1/3] Serve SPA fallback with 200 for routable paths in prod static serving Direct loads of valid dynamic-route URLs (e.g. /articles/7) in self-hosted prod returned HTTP 404 with the SPA fallback body, making them indistinguishable from genuinely unknown paths (bad for SEO, uptime monitors, and anything trusting status codes). PrecompressedStaticFiles now accepts a route matcher: when the html-mode 404.html fallback is hit for a path that matches the app's route table, it is served with status 200; unknown paths keep the 404 status. The backend-mounted frontend passes app.router directly. For the standalone prod static server (frontend-only mode), the compiler now persists the route table to .web/routes.json at compile time and the mount builds a matcher from it, falling back to the previous behavior when no manifest exists. Configured frontend_path prefixes are restored before matching since the mount strips them from request paths. Fixes reflex-dev/reflex#6983 --- .../src/reflex_base/constants/base.py | 3 + reflex/app.py | 13 +- reflex/compiler/compiler.py | 7 ++ reflex/utils/exec.py | 49 +++++++- reflex/utils/precompressed_staticfiles.py | 30 +++-- .../test_precompressed_frontend.py | 27 ++++ tests/units/test_app.py | 13 ++ tests/units/utils/test_exec.py | 67 ++++++++++ .../utils/test_precompressed_staticfiles.py | 119 ++++++++++++++++++ 9 files changed, 316 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/constants/base.py b/packages/reflex-base/src/reflex_base/constants/base.py index b5c9079517e..0fc55ebdd9b 100644 --- a/packages/reflex-base/src/reflex_base/constants/base.py +++ b/packages/reflex-base/src/reflex_base/constants/base.py @@ -58,6 +58,9 @@ class Dirs(SimpleNamespace): ENV_JSON = "env.json" # The name of the reflex json file. REFLEX_JSON = "reflex.json" + # JSON-encoded list of the app's page routes, written at compile time so the + # prod static file server can tell routable SPA paths from unknown ones. + ROUTES_MANIFEST = "routes.json" # The name of the postcss config file. POSTCSS_JS = "postcss.config.js" # The name of the states directory. diff --git a/reflex/app.py b/reflex/app.py index 96fe3151cfa..811065f2c6d 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -786,7 +786,7 @@ def __call__(self) -> ASGIApp: if environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.get(): from reflex.utils.exec import get_frontend_mount - asgi_app.routes.append(get_frontend_mount()) + asgi_app.routes.append(get_frontend_mount(router=self.router)) if self.api_transformer is not None: api_transformers: Sequence[Starlette | Callable[[ASGIApp], ASGIApp]] = ( @@ -1314,6 +1314,15 @@ def _compile_page(self, route: str, save_page: bool = True): if save_page: self._pages[route] = component + @property + def _page_routes(self) -> list[str]: + """Get all registered page routes in registration order. + + Returns: + The deduplicated list of page routes. + """ + return list(dict.fromkeys([*self._unevaluated_pages, *self._pages])) + @functools.cached_property def router(self) -> Callable[[str], str | None]: """The route computer function. @@ -1323,7 +1332,7 @@ def router(self) -> Callable[[str], str | None]: """ from reflex.route import get_router - return get_router(list(dict.fromkeys([*self._unevaluated_pages, *self._pages]))) + return get_router(self._page_routes) def get_load_events(self, path: str) -> list[IndividualEventType[()]]: """Get the load events for a route. diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 3ba8746316e..e191a9113a9 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -1418,6 +1418,13 @@ def add_save_task( prerender_routes=prerender_routes, ) + # Persist the route table so the standalone prod static server can serve + # routable SPA paths with 200 and reserve 404 for unknown ones. + compile_results.append(( + constants.Dirs.ROUTES_MANIFEST, + json.dumps(app._page_routes), + )) + if is_prod_mode(): purge_web_pages_dir() else: diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index c93b11949c3..cbbb19ab807 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import functools import hashlib import importlib.util import json @@ -12,7 +13,7 @@ import re import subprocess import sys -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import Any, NamedTuple, TypedDict @@ -340,9 +341,45 @@ def notify_app_running(): console.rule("[bold green]App Running") -def get_frontend_mount(): +def _match_with_frontend_path( + router: Callable[[str], str | None], path: str +) -> str | None: + """Match a mount-relative path against a route matcher expecting the frontend path. + + Args: + router: The app route matcher. + path: The request path with the frontend path prefix already stripped. + + Returns: + The matching route, or None if no route matches. + """ + return router(get_config().prepend_frontend_path(path)) + + +def get_routes_manifest_router() -> Callable[[str], str | None] | None: + """Build a route matcher from the routes manifest written at compile time. + + Returns: + A route matcher, or None when no manifest exists. + """ + from reflex.route import get_router + + manifest = get_web_dir() / constants.Dirs.ROUTES_MANIFEST + try: + routes = json.loads(manifest.read_text()) + except (OSError, ValueError): + return None + return get_router(routes) + + +def get_frontend_mount(router: Callable[[str], str | None] | None = None): """Get a Starlette Mount for the compiled frontend static files. + Args: + router: Optional route matcher (e.g. ``app.router``) used to serve + routable SPA paths with status 200 instead of 404. When None, a + matcher is built from the compiled routes manifest if present. + Returns: A Mount serving the compiled frontend static files. """ @@ -353,6 +390,13 @@ def get_frontend_mount(): config = get_config() + if router is None: + router = get_routes_manifest_router() + if router is not None and config.frontend_path: + # The mount strips the frontend path from request paths, but the route + # matcher expects it present (it strips the prefix itself). + router = functools.partial(_match_with_frontend_path, router) + static_dir = ( prerequisites.get_web_dir() / constants.Dirs.STATIC @@ -365,6 +409,7 @@ def get_frontend_mount(): directory=static_dir, html=True, encodings=config.frontend_compression_formats, + router=router, ), name="frontend", ) diff --git a/reflex/utils/precompressed_staticfiles.py b/reflex/utils/precompressed_staticfiles.py index 1c8cd690a67..4f9f199da12 100644 --- a/reflex/utils/precompressed_staticfiles.py +++ b/reflex/utils/precompressed_staticfiles.py @@ -4,7 +4,7 @@ import os import stat -from collections.abc import Sequence +from collections.abc import Callable, Sequence from dataclasses import dataclass from functools import lru_cache from mimetypes import guess_type @@ -72,6 +72,7 @@ def __init__( self, *args, encodings: Sequence[str] = (), + router: Callable[[str], str | None] | None = None, **kwargs, ): """Initialize the static file server. @@ -79,10 +80,15 @@ def __init__( Args: *args: Passed through to ``StaticFiles``. encodings: Ordered list of supported precompressed formats. + router: Optional route matcher taking the request path (with leading + slash) and returning the matching app route, or ``None``. Paths + with no on-disk file that match a route are served the SPA + fallback with status 200 instead of 404. **kwargs: Passed through to ``StaticFiles``. """ super().__init__(*args, **kwargs) self._encodings = tuple(_SUPPORTED_ENCODINGS[name] for name in encodings) + self._router = router def _select_sidecar( self, full_path: str | PathLike[str], scope: Scope @@ -178,16 +184,24 @@ async def get_response(self, path: str, scope: Scope) -> Response: The resolved static response for the request. """ response = await super().get_response(path, scope) - # Starlette's get_response builds the 404.html fallback with bare FileResponse, - # bypassing file_response. Re-route it so the sidecar/Vary handling applies. if ( - self._encodings - and self.html + self.html and isinstance(response, FileResponse) and response.status_code == 404 and response.stat_result is not None ): - return self.file_response( - response.path, response.stat_result, scope, status_code=404 - ) + # SPA fallback: a path with no prerendered file that still matches + # the app's route table is a valid page, so serve it with 200 and + # reserve 404 for genuinely unknown paths. + if self._router is not None and self._router("/" + path) is not None: + return self.file_response( + response.path, response.stat_result, scope, status_code=200 + ) + # Starlette's get_response builds the 404.html fallback with bare + # FileResponse, bypassing file_response. Re-route it so the + # sidecar/Vary handling applies. + if self._encodings: + return self.file_response( + response.path, response.stat_result, scope, status_code=404 + ) return response diff --git a/tests/integration/test_precompressed_frontend.py b/tests/integration/test_precompressed_frontend.py index 2fdf41a0d17..d9e8054dcfb 100644 --- a/tests/integration/test_precompressed_frontend.py +++ b/tests/integration/test_precompressed_frontend.py @@ -31,6 +31,11 @@ def index(): rx.text("Hello from Reflex"), ) + def article(): + return rx.el.main(rx.heading("Article")) + + app.add_page(article, route="articles/[id]") + @pytest.fixture(scope="module") def all_compression_formats_env() -> Generator[None, None, None]: @@ -114,3 +119,25 @@ def test_prod_frontend_serves_precompressed_404_fallback( assert headers["content-encoding"] == accept_encoding if magic is not None: assert body[: len(magic)] == magic + + +@pytest.mark.parametrize("accept_encoding", ["identity", "gzip"]) +def test_prod_frontend_serves_dynamic_route_with_200( + prod_test_app: AppHarnessProd, + accept_encoding: str, +): + """Direct loads of valid dynamic-route URLs get the SPA fallback with 200.""" + assert prod_test_app.frontend_url is not None + + status, headers, body = request_raw( + prod_test_app.frontend_url, + "/articles/7", + headers={"Accept-Encoding": accept_encoding}, + ) + + assert status == 200 + if accept_encoding == "gzip": + assert headers["content-encoding"] == "gzip" + assert body[:2] == b"\x1f\x8b" + else: + assert b" str | None: + return "articles/[id]" if path.startswith("/articles/") else None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("encodings", [[], ["gzip"]], ids=["identity", "gzip"]) +async def test_routable_spa_fallback_served_with_200( + tmp_path: Path, encodings: list[str] +): + """Serve the SPA fallback with 200 for paths that match the route table.""" + (tmp_path / "404.html").write_text("spa-fallback") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=encodings, + router=_articles_router, + ) + + scope = _scope("/articles/7") + response = await static_files.get_response("articles/7", scope) + + assert isinstance(response, FileResponse) + assert response.status_code == 200 + assert str(response.path).endswith("404.html") + assert response.media_type == "text/html" + assert await _collect_body(response, scope) == b"spa-fallback" + + +@pytest.mark.asyncio +async def test_routable_spa_fallback_serves_precompressed_sidecar(tmp_path: Path): + """Serve the precompressed fallback sidecar with 200 for routable paths.""" + (tmp_path / "404.html").write_text("spa-fallback") + (tmp_path / "404.html.gz").write_bytes(b"compressed-fallback") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=["gzip"], + router=_articles_router, + ) + + scope = _scope("/articles/7", "gzip") + response = await static_files.get_response("articles/7", scope) + + assert isinstance(response, FileResponse) + assert response.status_code == 200 + assert str(response.path).endswith("404.html.gz") + assert response.headers["content-encoding"] == "gzip" + assert await _collect_body(response, scope) == b"compressed-fallback" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("encodings", [[], ["gzip"]], ids=["identity", "gzip"]) +async def test_unroutable_spa_fallback_kept_as_404( + tmp_path: Path, encodings: list[str] +): + """Keep serving 404 for fallback paths that match no route.""" + (tmp_path / "404.html").write_text("spa-fallback") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=encodings, + router=_articles_router, + ) + + scope = _scope("/definitely-not-a-page") + response = await static_files.get_response("definitely-not-a-page", scope) + + assert isinstance(response, FileResponse) + assert response.status_code == 404 + assert await _collect_body(response, scope) == b"spa-fallback" + + +@pytest.mark.asyncio +async def test_missing_file_with_extension_matching_route_serves_fallback_200( + tmp_path: Path, +): + """A routable path is routable even when it looks like a file name.""" + (tmp_path / "404.html").write_text("spa-fallback") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=[], + router=_articles_router, + ) + + response = await static_files.get_response( + "articles/report.pdf", _scope("/articles/report.pdf") + ) + + assert isinstance(response, FileResponse) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_existing_file_not_rerouted_by_router(tmp_path: Path): + """Real files are served directly even when their path matches a route.""" + (tmp_path / "articles").mkdir() + (tmp_path / "articles" / "7").write_text("real-file") + + static_files = PrecompressedStaticFiles( + directory=tmp_path, + html=True, + encodings=[], + router=_articles_router, + ) + + scope = _scope("/articles/7") + response = await static_files.get_response("articles/7", scope) + + assert isinstance(response, FileResponse) + assert response.status_code == 200 + assert str(response.path).endswith("7") + assert await _collect_body(response, scope) == b"real-file" + + @pytest.mark.asyncio async def test_precompressed_static_files_prefers_best_accept_encoding( tmp_path: Path, From 38f37897f3bb9b60501ced6e0cd3a7332c22952d Mon Sep 17 00:00:00 2001 From: FarhanAliRaza Date: Fri, 28 Aug 2026 21:27:52 +0000 Subject: [PATCH 2/3] Address review comments: keep /404 unroutable, add news fragments and test coverage - Exclude the compiler's synthetic 404 page route from the SPA-fallback status matcher in get_frontend_mount, so a literal /404 request keeps its 404 status (with regression tests for both the manifest-built and explicitly passed routers). - Add news fragments for reflex and reflex-base. - Assert Vary: Accept-Encoding on the 200 SPA-fallback responses in the gzip test cases. - Add the missing docstring on the _articles_router test helper and fix the _page_routes property docstring lint. --- news/6996.bugfix.md | 1 + packages/reflex-base/news/6996.bugfix.md | 1 + reflex/app.py | 2 +- reflex/utils/exec.py | 28 +++++++++++-- tests/units/utils/test_exec.py | 40 +++++++++++++++++++ .../utils/test_precompressed_staticfiles.py | 13 ++++++ 6 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 news/6996.bugfix.md create mode 100644 packages/reflex-base/news/6996.bugfix.md diff --git a/news/6996.bugfix.md b/news/6996.bugfix.md new file mode 100644 index 00000000000..82eb53a9953 --- /dev/null +++ b/news/6996.bugfix.md @@ -0,0 +1 @@ +Serve valid dynamic-route URLs (e.g. `/articles/7`) with HTTP 200 instead of 404 when loaded directly in self-hosted prod static serving, reserving 404 for genuinely unknown paths. diff --git a/packages/reflex-base/news/6996.bugfix.md b/packages/reflex-base/news/6996.bugfix.md new file mode 100644 index 00000000000..f9b153ab2c4 --- /dev/null +++ b/packages/reflex-base/news/6996.bugfix.md @@ -0,0 +1 @@ +Add the `routes.json` manifest name constant, written at compile time so the prod static file server can tell routable SPA paths from unknown ones. diff --git a/reflex/app.py b/reflex/app.py index 811065f2c6d..2d662911cad 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1316,7 +1316,7 @@ def _compile_page(self, route: str, save_page: bool = True): @property def _page_routes(self) -> list[str]: - """Get all registered page routes in registration order. + """All registered page routes in registration order. Returns: The deduplicated list of page routes. diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index cbbb19ab807..bb44fb303d9 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -341,6 +341,24 @@ def notify_app_running(): console.rule("[bold green]App Running") +def _match_routable_page(router: Callable[[str], str | None], path: str) -> str | None: + """Match a path against the app routes, treating the 404 page as unroutable. + + The compiler registers a synthetic ``404`` page, so a literal ``/404`` + request would otherwise count as routable and lose its 404 status. + + Args: + router: The app route matcher. + path: The request path. + + Returns: + The matching route, or None when the path matches no route or only the + 404 page. + """ + route = router(path) + return route if route != constants.Page404.SLUG else None + + def _match_with_frontend_path( router: Callable[[str], str | None], path: str ) -> str | None: @@ -392,10 +410,12 @@ def get_frontend_mount(router: Callable[[str], str | None] | None = None): if router is None: router = get_routes_manifest_router() - if router is not None and config.frontend_path: - # The mount strips the frontend path from request paths, but the route - # matcher expects it present (it strips the prefix itself). - router = functools.partial(_match_with_frontend_path, router) + if router is not None: + router = functools.partial(_match_routable_page, router) + if config.frontend_path: + # The mount strips the frontend path from request paths, but the + # route matcher expects it present (it strips the prefix itself). + router = functools.partial(_match_with_frontend_path, router) static_dir = ( prerequisites.get_web_dir() diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 3844a7a96a8..99069233402 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -183,3 +183,43 @@ def test_get_frontend_mount_router_respects_frontend_path( assert router("/articles/7") == "articles/[id]" assert router("/") == "index" assert router("/missing") is None + + +def test_get_frontend_mount_router_excludes_synthetic_404_route( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """A literal /404 request stays a 404 despite the compiled 404 page route.""" + monkeypatch.setenv(environment.REFLEX_WEB_WORKDIR.name, str(tmp_path)) + (tmp_path / "build" / "client").mkdir(parents=True) + (tmp_path / "routes.json").write_text('["index", "articles/[id]", "404"]') + + mount = exec_utils.get_frontend_mount() + + static_files = mount.app + assert isinstance(static_files, PrecompressedStaticFiles) + router = static_files._router + assert router is not None + assert router("/404") is None + assert router("/404/") is None + assert router("/articles/7") == "articles/[id]" + + +def test_get_frontend_mount_explicit_router_excludes_synthetic_404_route( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """An explicitly passed app router is also filtered for the 404 page route.""" + from reflex.route import get_router + + monkeypatch.setenv(environment.REFLEX_WEB_WORKDIR.name, str(tmp_path)) + (tmp_path / "build" / "client").mkdir(parents=True) + + mount = exec_utils.get_frontend_mount( + router=get_router(["index", "articles/[id]", "404"]) + ) + + static_files = mount.app + assert isinstance(static_files, PrecompressedStaticFiles) + router = static_files._router + assert router is not None + assert router("/404") is None + assert router("/articles/7") == "articles/[id]" diff --git a/tests/units/utils/test_precompressed_staticfiles.py b/tests/units/utils/test_precompressed_staticfiles.py index b697c91175d..5cf8bc57c7b 100644 --- a/tests/units/utils/test_precompressed_staticfiles.py +++ b/tests/units/utils/test_precompressed_staticfiles.py @@ -97,6 +97,14 @@ async def test_precompressed_static_files_supports_html_404_fallback(tmp_path: P def _articles_router(path: str) -> str | None: + """Match paths belonging to the dynamic articles route. + + Args: + path: The request path with leading slash. + + Returns: + The articles route for paths under ``/articles/``, otherwise None. + """ return "articles/[id]" if path.startswith("/articles/") else None @@ -122,6 +130,10 @@ async def test_routable_spa_fallback_served_with_200( assert response.status_code == 200 assert str(response.path).endswith("404.html") assert response.media_type == "text/html" + if encodings: + assert response.headers["vary"] == "Accept-Encoding" + else: + assert "vary" not in response.headers assert await _collect_body(response, scope) == b"spa-fallback" @@ -145,6 +157,7 @@ async def test_routable_spa_fallback_serves_precompressed_sidecar(tmp_path: Path assert response.status_code == 200 assert str(response.path).endswith("404.html.gz") assert response.headers["content-encoding"] == "gzip" + assert response.headers["vary"] == "Accept-Encoding" assert await _collect_body(response, scope) == b"compressed-fallback" From 319b3eb129780c544af8649371b72bac81277a66 Mon Sep 17 00:00:00 2001 From: FarhanAliRaza Date: Fri, 28 Aug 2026 22:51:52 +0000 Subject: [PATCH 3/3] Cover corrupt routes manifest in get_routes_manifest_router tests Ports the one test scenario from PR #6469 that the superseding implementation did not already cover: a routes.json that fails to parse must disable the SPA-fallback router (return None) rather than raise. --- tests/units/utils/test_exec.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 99069233402..02df27cdf29 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -127,6 +127,15 @@ def test_get_routes_manifest_router_missing_manifest( assert exec_utils.get_routes_manifest_router() is None +def test_get_routes_manifest_router_invalid_manifest( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Return None when the routes manifest is not valid JSON.""" + monkeypatch.setenv(environment.REFLEX_WEB_WORKDIR.name, str(tmp_path)) + (tmp_path / "routes.json").write_text("not valid json{") + assert exec_utils.get_routes_manifest_router() is None + + def test_get_routes_manifest_router_matches_dynamic_routes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ):