Repository navigation
Fix ui.sub_pages staying in the SPA for pages registered via app.include_router() - #6374
Conversation
…nclude_router()` When `ui.sub_pages` cannot resolve a path, it checks whether another page builder serves it and falls back to a full page load. That check missed pages from an included `APIRouter` for two reasons: - A link like "/other" to a page registered as "/" on a prefixed router (route "/other/") found no literal match. The server answers such a request with a redirect to the slash-toggled path, so the check now mirrors Starlette's `redirect_slashes`. - FastAPI 0.141+ keeps an included router as a single lazy entry in `app.routes`, which the check skipped. `App._iter_http_routes()` now looks inside. The reset between tests drops these lazy entries, so router pages no longer leak from one test into the next. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
evnchn
left a comment
There was a problem hiding this comment.
Approving: the fix holds on FastAPI 0.136.3 and the current 0.142.2, and the new test fails without it on both.
In a real browser, 552 sub-pages clicks across six app layouts gave no regression against main, including the 3.17.1 case of relative links on /other/.
One edge case only: behind a proxy that strips the path prefix without rewriting redirects, the link now ends on the proxy's 404 instead of the sub-page 404 (folded below).
Issue 6281 reproduced on main, fixed on this PR (headless Chromium, both FastAPI versions)
App: APIRouter(prefix='/other') + @router.page('/'), linked from a ui.sub_pages page at /.
"SPA" means the click stayed in the document; "full load" means it left.
| Link | FastAPI | main |
PR | Implications |
|---|---|---|---|---|
/other |
0.136.3 | SPA, 404: sub page /other not found |
full load, lands on /other/ |
Cause 1 (trailing slash) is fixed |
/other |
0.142.2 | SPA 404 | full load, /other/ |
Cause 1 is fixed with the lazy router too |
/other/ |
0.136.3 | full load | full load | Was already fine; unchanged |
/other/ |
0.142.2 | SPA 404 | full load | Cause 2 (_IncludedRouter) is fixed |
/other?x=1, /other#frag |
both | SPA 404 | /other/?x=1, /other/#frag |
The query string and fragment survive the 307 |
/outer/inner (nested router), /inc (prefix at include time), /item/5 (/{id}) |
0.142.2 | SPA 404 | full load, right page | The recursion in iter_route_contexts and path params work |
The 3.17.1 case: relative links on a prefixed-router page
Starting at /, click the SPA link to /other, then on the router page click ui.link('rel', '4711') or ui.button(on_click=lambda: ui.navigate.to('4711')).
| Layout | FastAPI | main |
PR | Implications |
|---|---|---|---|---|
ui.run() |
both | never gets to the router page (SPA 404) | /other/4711 |
The browser ends up on the canonical /other/, so relative URLs resolve below it, unlike 6288 |
ui.run(root=...) |
both | SPA 404 | /other/4711 |
The same holds for a root function |
ui.run_with(mount_path='/gui') |
both | SPA 404 | /gui/other/4711 |
The redirect keeps the mount prefix |
Opening /other directly answers 307 → /other/ on both versions, the same as main, because this PR does not change route registration.
Regression hunt: 552 clicks, main vs PR
There were 23 targets per layout, each clicked once as a link and once through ui.navigate.to, on both FastAPI versions.
Targets: /other, /other/, /other?x=1, /other#frag, /other/?x=1#frag, /other//, /other/4711, /outer/inner[/], /inc[/], /item/5[/], /plain[/], /nope[/], /api/data[/], /static/f.txt, /, /sub[/].
| Layout | 0.136.3 same / fixed / worse | 0.142.2 same / fixed / worse | Implications |
|---|---|---|---|
@ui.page('/') + ui.sub_pages |
30 / 16 / 0 | 18 / 28 / 0 | Every difference is a sub-page 404 that now reaches the real page |
Wildcard layout ('/' + '/{_:path}', same builder) |
46 / 0 / 0 | 34 / 12 / 0 | /other stays in the SPA, matching the server (GET /other → 404, no redirect) |
app.router.redirect_slashes = False |
46 / 0 / 0 | 34 / 12 / 0 | No slash toggling; the 0.142.2 gains are exact-path router pages |
ui.run(root=...) |
30 / 16 / 0 | 18 / 28 / 0 | Root clients (no scope['route']) behave like page clients |
ui.run_with(..., mount_path='/gui') |
30 / 16 / 0 | 18 / 28 / 0 | The probe works on app-relative paths, and the redirect keeps /gui |
@app.post('/other') beside the router page |
36 / 10 / 0 | 24 / 22 / 0 | The partial match blocks toggling, so the SPA shows the 404 instead of the server's 405 |
Paths that should 404 (/nope, /nope/, /other//) stay in the SPA in every layout, on both trees.
Every "stays in the SPA" row is a path where the server does not serve a page either (curl): wildcard GET /other → 404, redirect_slashes=False → 404, POST sibling GET /other → 405 and GET /other// → 307 → 405, plain GET /other// → 404.
A SPA shell that is itself a router page (APIRouter(prefix='/spa'), '/' + '/{_:path}') kept /spa/nope inside the SPA on 0.142.2.
That shows the same-builder skip still recognizes the endpoint through RouteContext.
Back and forward after leaving the SPA behaved the same on main (via /plain) and the PR (via /other/).
Caveat: a stripping reverse proxy that does not rewrite Location (not blocking)
The fix hands the slash fix to the server's 307, so a proxy that strips the prefix must rewrite the redirect.
NiceGUI's own examples/nginx_subpath config does this (proxy_redirect ~^https?://[^/]+(/.*)$ /nicegui$1;).
Tested with nginx in front at /gui/ (X-Forwarded-Prefix: /gui), in headless Chromium:
| nginx config | Link | main |
PR | Implications |
|---|---|---|---|---|
As in examples/nginx_subpath |
/other, /plain/ |
SPA 404 | /gui/other/, /gui/plain |
Works on both FastAPI versions |
Same, without proxy_redirect |
/other, /plain/ |
SPA 404 | full load to /other/ → nginx 404 outside /gui |
Both fail; the PR's failure unloads the SPA and leaves the prefix |
| Either | /other/ |
full load on 0.136.3, SPA 404 on 0.142.2 | /gui/other/ |
No redirect involved; the lazy-router half alone fixes 0.142.2 |
Typing /gui/other in the address bar already breaks the same way on main with that config, so this is an existing deployment requirement, now reached from SPA links too.
It may be worth a line in the release notes, but it does not need a code change.
Tests: pytest -rs, and the new test with parts of the fix reverted
tests/test_sub_pages.py tests/test_api_router.py tests/test_run_with.py tests/test_navigate.py: 72 passed, 0 skipped, on both FastAPI 0.136.3 and 0.142.2 (Starlette 1.3.1 and 1.7.0).
| Variant | 0.136.3 | 0.142.2 | Implications |
|---|---|---|---|
| PR as is | 2 passed | 2 passed | — |
sub_pages_router.py reverted |
2 failed | 2 failed | The test guards the fix |
Only _iter_http_routes() swapped back to isinstance(route, Route) |
2 passed | 2 failed | The lazy-router half is needed on 0.141+ only, as stated |
| Only the slash probe disabled | 2 failed | 2 failed | The probe is needed on both |
testing/general.py reverted, test_api_router.py run as a file |
3 passed | 1 failed, 2 passed | This confirms the reset change and the cross-test leak described in the PR |
CI pins 0.136.3, so the lazy path stays unexercised in CI, as the PR says; the follow-up issue linked in the PR tracks that.
Cost
The match runs only when ui.sub_pages cannot resolve a path, because of the short-circuit in _handle_navigate, so it does not run on every navigation.
| 250 routers × 2 pages | main |
PR | Implications |
|---|---|---|---|
| 0.136.3 | 0.18 ms/call | 0.27 ms/call | Negligible |
| 0.142.2 | 0.006 ms/call (sees no router routes) | 0.89 ms/call | Under 1 ms, paid once per unresolved click |
Setup
PR head 1c15d078, merge base 19cc869c5, Python 3.12, uv venvs with fastapi==0.136.3 + starlette==1.3.1 + anyio==4.14.2 (lock) and fastapi==0.142.2 + starlette==1.7.0.
nicegui.__file__ was checked to resolve to the PR worktree, or to the base worktree through PYTHONPATH.
Browser: Playwright Chromium, headless.
A window.__probe flag set before each click tells an SPA navigation from a full load.
* main: (38 commits) Fix `ui.sub_pages` staying in the SPA for pages registered via `app.include_router()` (zauberzeug#6374) fix Dependabot alerts 419-446, 448-453, 455-466 and 469-475 fix Dependabot alerts 416, 417 and 418 Fix element staying registered when constructor raises after registration (zauberzeug#6369) Fix Redis tab storage no longer receiving changes after a page reload (zauberzeug#6365) Fix `ui.codemirror` leaving its editor view alive after a remount (zauberzeug#6368) Report exceptions from fire-and-forget tasks to `ui.on_exception` (zauberzeug#6363) Fix duplicated tab sharing nested tab storage values with the original tab (zauberzeug#6364) Fix scene docstrings for material side and spot light defaults (zauberzeug#6243) Fix missing tab storage creation in the On Air handshake (zauberzeug#6332) Explain JavaScript expressions in AG Grid options and log unknown row IDs (zauberzeug#6362) Create tab storage before invoking connect handlers (zauberzeug#6355) Fix button.clicked() stacking a new click listener on every await (zauberzeug#6333) Fix Leaflet element outliving its client through Layer.current_leaflet (zauberzeug#6345) Keep the website header on a single line at every viewport width Add a sitemap and robots.txt to the website (zauberzeug#6357) Add Chinese translation to the website (zauberzeug#6350) Close deferred background tasks when the test fixtures reset the app (zauberzeug#6359) Fix `make_sortable` for containers rendered after page load (zauberzeug#6358) Let `ElementFilter` match the values that input elements display (zauberzeug#6352) ...
Motivation
Fixes #6281, which was reopened after #6346.
When
ui.sub_pagescannot resolve a path,SubPagesRouter._other_page_builder_matches_path()checks whether another page builder serves it and falls back to a full page load. For pages registered through anAPIRouterandapp.include_router()that check comes up empty, so the user sees the sub-page 404 instead of the other page. There are two independent causes:APIRouter(prefix='/other')and@router.page('/')the page lives at/other/. A link to/otherworks fine as a real request: no route matches, so Starlette'sredirect_slashesanswers with a 307 to/other/. The check only did a literal route match and gotMatch.NONE.app.include_router()adds a single_IncludedRouterentry toapp.routesinstead of the individual routes. Theisinstance(route, Route)filter skips it, so the check sees no router pages at all — not even for/other/. Our lock file pins FastAPI 0.136.3, but users get the current version.#6288 solved the first cause by registering the bare prefix, which broke relative links on such pages and was reverted in #6346. As outlined in the reopening comment, this PR fixes the matching side instead and leaves route registration alone.
Implementation
Trailing slash:
_other_page_builder_matches_path()now mirrors the redirect step ofstarlette.routing.Router.appbefore it looks for another page builder. The path is replaced by its twin with toggled trailing slash if, and only if, the server would redirect it:redirect_slashesis enabled on the app's router,The existing loop then runs against the path the server would end up serving.
client.open()still receives the original path, so the redirect itself is left to the server and the browser ends up on the canonical URL with query string and fragment intact. That is why the test now expects/other/: it is the URL the browser shows after requesting/otherdirectly, and the one that makes relative links on the page work (#6346).This is the probe that #6288 dropped, but tied to the conditions under which Starlette really redirects. The four divergences listed back then are covered:
'/'+'/{_:path}'on the same builder) plus a router page at/other//other, so the server would not redirect. The link stays in the SPA and shows the 404 without a reload — the same result as requesting/otherdirectly.redirect_slashes=Falseclient.open('').rstrip('/')like Starlette does.The fix works in both directions: a link to
/other/now also reaches a page registered as@ui.page('/other'), where it showed the sub-page 404 before.The matching scope is now passed to every route instead of page routes only, so it carries the headers of the client's request.
Hostroutes read them and would raise aKeyErrorotherwise.Lazily included routers: The new
App._iter_http_routes()yields all HTTP routes, including those inside included routers. On FastAPI 0.141+ it uses the publicfastapi.routing.iter_route_contexts, whose route contexts know the full path with all prefixes and expose the endpoint. On older versions it yields the routes fromapp.routesas before._other_page_builder_matches_path()takes its candidates from there, which is a one-line change.nicegui_reset_globals()additionally drops the lazy entries. Without that, router pages survive the reset on FastAPI 0.141+, and a test can be served a page from an earlier one.Tests:
test_navigate_from_sub_pages_to_api_router_pageloses itsxfailmarker and is parametrized over both ways to set the prefix, on the router and at include time. The reset has no test of its own: on FastAPI 0.141+ the tests intests/test_api_router.pyserve each other's pages without it.CI runs FastAPI 0.136.3 and cannot exercise the lazy code path, so I ran the tests once against FastAPI 0.142.2:
tests/test_sub_pages.py,tests/test_api_router.py,tests/test_endpoint_docs.pymain: the sub-pages test above, and one test oftests/test_api_router.pywhen the file runs as a wholeredirect_slashes=False, reverse direction, query string with a root functionI did not commit the temporary tests: most of them pin behavior that this PR leaves unchanged.
Not part of this PR: Two more places look at
app.routesand still miss router pages on FastAPI 0.141+,App.remove_route()and theendpoint_documentationsetting. Fixing them means modifying routes inside an included router, for which FastAPI has no public API. That, the lock file bump and a memory finding that currently blocks it are tracked in #6373.Progress
※