Skip to content

Fix ui.sub_pages staying in the SPA for pages registered via app.include_router() - #6374

Merged
falkoschindler merged 1 commit into
mainfrom
fix-sub-pages-redirect-slashes
Oct 5, 2026
Merged

falkoschindler merged 1 commit into
mainfrom
fix-sub-pages-redirect-slashes

Conversation

@falkoschindler

Copy link
Copy Markdown
Contributor

Motivation

Fixes #6281, which was reopened after #6346.

When ui.sub_pages cannot 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 an APIRouter and app.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:

  1. The trailing slash. With APIRouter(prefix='/other') and @router.page('/') the page lives at /other/. A link to /other works fine as a real request: no route matches, so Starlette's redirect_slashes answers with a 307 to /other/. The check only did a literal route match and got Match.NONE.
  2. FastAPI's lazy router inclusion. Since FastAPI 0.141, app.include_router() adds a single _IncludedRouter entry to app.routes instead of the individual routes. The isinstance(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 of starlette.routing.Router.app before 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_slashes is enabled on the app's router,
  • the path is not the root,
  • and no route at all matches the path as given — including the client's own routes, mounts and included routers, and counting partial matches.

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 /other directly, 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:

Case Behavior
Wildcard layout ('/' + '/{_:path}' on the same builder) plus a router page at /other/ The wildcard matches /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 /other directly.
redirect_slashes=False No toggling, the link stays in the SPA.
Link to the bare path prefix, which arrives as an empty path Excluded, so there is no client.open('').
Multiple trailing slashes Stripped with 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. Host routes read them and would raise a KeyError otherwise.

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 public fastapi.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 from app.routes as 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_page loses its xfail marker 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 in tests/test_api_router.py serve 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:

0.136.3 (lock) 0.142.2
tests/test_sub_pages.py, tests/test_api_router.py, tests/test_endpoint_docs.py pass pass
On main: the sub-pages test above, and one test of tests/test_api_router.py when the file runs as a whole — fail
Temporary tests: wildcard layout, the client's own wildcard page inside an included router pass pass
Temporary tests with an earlier revision: redirect_slashes=False, reverse direction, query string with a root function pass pass

I 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.routes and still miss router pages on FastAPI 0.141+, App.remove_route() and the endpoint_documentation setting. 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

  • The PR title is a short phrase starting with a verb like "Add ...", "Fix ...", "Update ...", "Remove ...", etc.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests have been added/updated.
  • Documentation is not necessary.
  • No breaking changes to the public API.

※

…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>
@falkoschindler falkoschindler added this to the 3.18 milestone Oct 5, 2026
@falkoschindler falkoschindler added bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review labels Oct 5, 2026
@falkoschindler
falkoschindler requested a review from evnchn October 5, 2026 08:15

@evnchn evnchn 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.

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.

※

@falkoschindler
falkoschindler added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit d00cc7a Oct 5, 2026
8 checks passed
@falkoschindler
falkoschindler deleted the fix-sub-pages-redirect-slashes branch October 5, 2026 15:55
SaadZahem added a commit to SaadZahem/nicegui that referenced this pull request Oct 5, 2026
* 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)
  ...
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Type/scope: Incorrect behavior in existing functionality review Status: PR is open and needs review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui.sub_pages stays in the SPA instead of loading pages registered via app.include_router()

2 participants