Serve SPA fallback with 200 for routable paths in prod static serving - #6996
Serve SPA fallback with 200 for routable paths in prod static serving#6996FarhanAliRaza wants to merge 3 commits into
Conversation
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#6983
Greptile SummaryThe PR updates production static serving so routable SPA fallback paths return HTTP 200 while unknown paths retain HTTP 404.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| reflex/utils/precompressed_staticfiles.py | Adds route-aware status selection for SPA fallback responses while retaining precompressed sidecar handling. |
| reflex/utils/exec.py | Loads the compiled route manifest and configures frontend mounts with normalized route matching. |
| reflex/compiler/compiler.py | Writes the registered page routes to a generated manifest for standalone production serving. |
| reflex/app.py | Shares the application router with backend-mounted static serving and centralizes page-route collection. |
| tests/units/utils/test_precompressed_staticfiles.py | Covers routable and unknown fallback statuses, compressed responses, and includes the requested helper docstring. |
| tests/units/utils/test_exec.py | Covers route-manifest loading and frontend-path-aware mount behavior. |
| tests/integration/test_precompressed_frontend.py | Verifies direct production loads of a dynamic route return 200 with identity and gzip responses. |
Reviews (4): Last reviewed commit: "Cover corrupt routes manifest in get_rou..." | Re-trigger Greptile
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 139ba3c7b4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
All reported issues were addressed across 9 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
… 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.
|
seems like this one supersedes #6469 ? can you check to see if there is any overlap or anything missed in the earlier PR, integrate it here, then close the old one |
Ports the one test scenario from PR reflex-dev#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.
There was a problem hiding this comment.
Production-readiness review. I read the diff together with the route matcher, static-file server, build layout, and compiler manifest write, then built a sample app registering every route shape (/, /about, /my-page, /nested/deep/page, articles/[id], users/[uid]/posts/[pid], blog/[slug]/comments, docs/[[section]], files/[[...splat]], custom 404) and drove reflex run --env prod with raw HTTP requests (identity and gzip, GET and HEAD) plus headless Chromium for direct loads, client-side navigation, and backend event round-trips.
Configurations exercised (Linux)
| mode | frontend_path |
prerender | result |
|---|---|---|---|
| backend-mounted | none | on | pass |
| backend-mounted | /app |
on | pass |
| backend-mounted | /a/b |
on | pass |
| backend-mounted | none | off (REFLEX_SSR=false) |
pass |
| frontend-only (standalone static server, manifest path) | none | on | pass |
| frontend-only | /app |
on | pass |
In every configuration: routable paths (/articles/7, /articles/7/, /users/1/posts/2, /blog/x/comments, /docs, /docs/intro, /files, /files/a/b/c, /articles/report.pdf) return 200 with the SPA fallback, correct Content-Encoding/Vary, and the browser renders the right page with the right params; unroutable paths (/articles, /articles/7/extra, /docs/a/b, /users/1, /does-not-exist, /404, /404/, /nope.js, /ABOUT) stay 404 and render the custom 404 page; real files are unaffected. A baseline run on main returns 404 for every dynamic, optional, and catch-all path above, so the change does what it claims. With prerendering off the improvement is larger than the description says: plain static pages like /about were also 404 before.
Unit tests for the touched modules, ruff check/format, pyright, and tests/integration/test_precompressed_frontend.py all pass locally.
Findings
- Blocking: no effect on Windows for multi-segment paths.
StaticFiles.get_pathnormalizes withos.path.normpath, so on Windowsget_responsereceivesarticles\7and the route regexes never match; every nested routable path keeps its 404 status. Windows CI does not catch it because the new unit tests callget_responsewith forward slashes directly. Details and a suggested one-line fix are inline; fix plus two regression tests are in 47b9992 onclaude/pr-6996-review-97oxj9, ready to cherry-pick. - Nit:
/indexanswers 200 but renders the 404 page.get_routeraliases/indexto the index route (pre-existing), so the server now reports 200 while React Router renders the 404 component. Harmless, but easy to exclude in_match_routable_pageif you want status and rendering consistent. - Nit: a corrupt manifest is silent.
get_routes_manifest_routerreturnsNoneonValueError, quietly reverting to the old 404 behavior. A warning log for the parse-error case would make an unexpected regression in a deployment diagnosable. - Nit: news fragment type.
packages/reflex-base/news/6996.bugfix.mddescribes a constant addition, which reads asmiscrather than a user-facing bugfix.
Unrelated and pre-existing (reproduced with this PR's files reverted to main): frontend_path combined with REFLEX_SSR=false crashes build() with FileNotFoundError when moving sitemap.xml.br into build/client/app/, because path_ops.mv does not create the prefix directory and with prerendering off React Router does not create it either (separate PR incoming).
Generated by Claude Code
| # 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: |
There was a problem hiding this comment.
Blocking on Windows: path here comes from StaticFiles.get_path, which normalizes with os.path.normpath, so on Windows it arrives as articles\7 (nested\deep\page, files\a\b, ...). The route regexes only understand /, so every multi-segment routable path keeps its 404 status there; only single-segment routes like /about benefit. The PR's unit tests pass on Windows CI because they call get_response("articles/7", ...) directly and never go through get_path.
Restoring the URL form before matching fixes it:
| if self._router is not None and self._router("/" + path) is not None: | |
| if ( | |
| self._router is not None | |
| and self._router("/" + path.replace(os.sep, "/")) is not None | |
| ): |
Regression tests (one driving the ASGI callable so Windows CI exercises the real get_path, one simulating the separator on any OS) are in 47b9992 on claude/pr-6996-review-97oxj9, ready to cherry-pick.
Generated by Claude Code
All Submissions:
Type of change
Changes To Core Features:
In self-hosted prod (
reflex run --env prod), a direct load of a valid dynamic-routeURL (e.g.
/articles/7) returned HTTP 404 with the SPA-fallback body, making validdynamic URLs indistinguishable from genuinely unknown paths — bad for SEO, uptime
monitors, and anything trusting status codes.
PrecompressedStaticFilesnow accepts a route matcher: when Starlette's html-mode404.htmlfallback is hit for a path that matches the app's route table, it is servedwith status 200; unroutable paths keep the 404 status. The backend-mounted frontend
passes
app.routerdirectly; for the standalone prod static server (frontend-onlymode), the compiler persists the route table to
.web/routes.jsonat compile time andthe mount builds a matcher from it, falling back to the previous behavior when no
manifest exists (the manifest lives outside
build/client, so it is not publiclyserved). Configured
frontend_pathprefixes are restored before matching since themount strips them from request paths.
Covered by new unit tests (routable → 200 incl. precompressed sidecars, unknown → 404,
real files unaffected, manifest loading,
frontend_pathhandling) and an extendedtests/integration/test_precompressed_frontend.pywith anarticles/[id]page.closes #6983