Skip to content

Commit 31c4a69

Browse files
falkoschindlerclaudeEvan Chan
authored
Fix 404 handler swallowing SPA fallback under ui.run_with(root=...) (#5999)
### Motivation Closes #5998. #5974 changed the 404 handler to return JSON when an endpoint matched but no `nicegui_page_path` was set, so that `@app.get` routes raising `HTTPException(404)` would no longer be served NiceGUI's HTML error page. The discriminator was `'endpoint' in request.scope`. That works under plain `ui.run()`. Under `ui.run_with(parent_app, root=root)` it breaks SPA fallback: Starlette's `Mount.matches()` populates `scope["endpoint"]` with the mounted app reference _before_ the inner router runs (see [starlette/routing.py L429](https://github.com/encode/starlette/blob/0.52.1/starlette/routing.py#L429)). When the inner router doesn't match anything (e.g. `/auth/login` is only known to a client-side `ui.sub_pages`), that `endpoint` survives into the 404 handler. The new branch then incorrectly fires and returns `{"detail":"Not Found"}` instead of falling through to render `root`. ### Implementation Tighten the discriminator: only treat the request as "an endpoint raised 404" when `scope["endpoint"]` is set _and_ is not `core.app` itself. A real inner `Route` match overwrites `endpoint` with the handler function, so this distinguishes the two cases cleanly. Added two regression tests in `tests/test_run_with.py` exercising the mounted-app code path that the existing `tests/test_page.py` tests don't cover: - `test_run_with_unknown_path_falls_through_to_root` — the actual regression guard (fails on `main`) - `test_run_with_api_endpoint_404_still_returns_json` — guards #5974's intent under `ui.run_with` ### Progress - [x] The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc. - [x] The implementation is complete. - [x] This PR does not address a security issue. - [x] Pytests have been added. - [x] Documentation is not necessary. - [x] No breaking changes to the public API. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Evan Chan <evan@nicegui.io>
1 parent b29ab8b commit 31c4a69

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

nicegui/nicegui.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,10 @@ async def _shutdown() -> None:
163163

164164
@app.exception_handler(404)
165165
async def _exception_handler_404(request: Request, exception: Exception) -> Response:
166-
if 'endpoint' in request.scope and not request.scope.get('nicegui_page_path') and isinstance(exception, StarletteHTTPException):
166+
if (endpoint := request.scope.get('endpoint')) is not None and endpoint is not app and not request.scope.get('nicegui_page_path') and isinstance(exception, StarletteHTTPException):
167167
# non-page endpoints raising 404 should get JSON, not our HTML error page
168168
# NOTE: match Starlette's HTTPException (the base class) so e.g. auth dependencies that raise it directly are covered
169+
# NOTE: when mounted via ui.run_with(), the parent's Mount sets endpoint=app even if no inner route matched — exclude that case
169170
return await http_exception_handler(request, exception)
170171
root = core.root
171172
if root is not None:

tests/test_run_with.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import weakref
22

33
import pytest
4-
from fastapi import FastAPI
4+
from fastapi import FastAPI, HTTPException
5+
from fastapi.testclient import TestClient
56

67
from nicegui import app, ui
78

@@ -19,3 +20,34 @@ async def test_run_with_tolerates_dangling_weakref_proxy(nicegui_reset_globals):
1920
ui.run_with(fastapi_app)
2021
async with fastapi_app.router.lifespan_context(fastapi_app):
2122
pass
23+
24+
25+
def test_run_with_unknown_path_falls_through_to_root(nicegui_reset_globals):
26+
"""SPA-style subpaths (handled client-side by ui.sub_pages) must render the root page, not JSON 404."""
27+
fastapi_app = FastAPI()
28+
29+
def root() -> None:
30+
ui.sub_pages({'/auth/login': lambda: ui.label('login')})
31+
32+
ui.run_with(fastapi_app, root=root)
33+
with TestClient(fastapi_app) as client:
34+
response = client.get('/auth/login')
35+
assert response.headers['content-type'].startswith('text/html'), \
36+
'unmatched path under ui.run_with(root=...) should render the root HTML page, not JSON'
37+
38+
39+
def test_run_with_api_endpoint_404_still_returns_json(nicegui_reset_globals):
40+
"""An @app.get endpoint raising 404 must still get FastAPI's JSON response, even when mounted via ui.run_with()."""
41+
fastapi_app = FastAPI()
42+
43+
@app.get('/api/missing')
44+
def api_missing():
45+
raise HTTPException(404, 'item not found')
46+
47+
ui.run_with(fastapi_app)
48+
with TestClient(fastapi_app) as client:
49+
response = client.get('/api/missing')
50+
assert response.status_code == 404
51+
assert response.headers['content-type'].startswith('application/json'), \
52+
'API endpoints raising HTTPException(404) should keep getting JSON'
53+
assert response.json() == {'detail': 'item not found'}

0 commit comments

Comments
 (0)