Skip to content

Serve SPA fallback with 200 for routable paths in prod static serving - #6996

Open
FarhanAliRaza wants to merge 3 commits into
reflex-dev:mainfrom
FarhanAliRaza:claude/dynamic-routes-prod-404-exwen5
Open

Serve SPA fallback with 200 for routable paths in prod static serving#6996
FarhanAliRaza wants to merge 3 commits into
reflex-dev:mainfrom
FarhanAliRaza:claude/dynamic-routes-prod-404-exwen5

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

In self-hosted prod (reflex run --env prod), a direct load of a valid dynamic-route
URL (e.g. /articles/7) returned HTTP 404 with the SPA-fallback body, making valid
dynamic URLs indistinguishable from genuinely unknown paths — bad for SEO, uptime
monitors, and anything trusting status codes.

PrecompressedStaticFiles now accepts a route matcher: when Starlette's html-mode
404.html fallback is hit for a path that matches the app's route table, it is served
with status 200; unroutable paths keep the 404 status. The backend-mounted frontend
passes app.router directly; for the standalone prod static server (frontend-only
mode), the compiler 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 (the manifest lives outside build/client, so it is not publicly
served). Configured frontend_path prefixes are restored before matching since the
mount strips them from request paths.

Covered by new unit tests (routable → 200 incl. precompressed sidecars, unknown → 404,
real files unaffected, manifest loading, frontend_path handling) and an extended
tests/integration/test_precompressed_frontend.py with an articles/[id] page.

closes #6983

Review in cubic

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
@FarhanAliRaza
FarhanAliRaza requested a review from a team as a code owner August 28, 2026 20:58
@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR updates production static serving so routable SPA fallback paths return HTTP 200 while unknown paths retain HTTP 404.

  • Persists the compiled route table in .web/routes.json for standalone frontend serving.
  • Passes route matchers into the static frontend mount, including frontend_path handling.
  • Preserves precompressed fallback responses and adds unit and integration coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

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

Comment thread tests/units/utils/test_precompressed_staticfiles.py
@codspeed-hq

codspeed-hq Bot commented Aug 28, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 32 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing FarhanAliRaza:claude/dynamic-routes-prod-404-exwen5 (319b3eb) with main (394e525)

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread packages/reflex-base/src/reflex_base/constants/base.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread reflex/utils/precompressed_staticfiles.py
Comment thread tests/units/utils/test_precompressed_staticfiles.py
… 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.
@masenf

masenf commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

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.

@masenf masenf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

  1. Blocking: no effect on Windows for multi-segment paths. StaticFiles.get_path normalizes with os.path.normpath, so on Windows get_response receives articles\7 and the route regexes never match; every nested routable path keeps its 404 status. Windows CI does not catch it because the new unit tests call get_response with forward slashes directly. Details and a suggested one-line fix are inline; fix plus two regression tests are in 47b9992 on claude/pr-6996-review-97oxj9, ready to cherry-pick.
  2. Nit: /index answers 200 but renders the 404 page. get_router aliases /index to 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_page if you want status and rendering consistent.
  3. Nit: a corrupt manifest is silent. get_routes_manifest_router returns None on ValueError, quietly reverting to the old 404 behavior. A warning log for the parse-error case would make an unexpected regression in a deployment diagnosable.
  4. Nit: news fragment type. packages/reflex-base/news/6996.bugfix.md describes a constant addition, which reads as misc rather 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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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:

Suggested change
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prod mode serves HTTP 404 status for direct loads of valid dynamic-route URLs (SPA fallback body renders fine)

2 participants