Repository navigation
fix: stop the browser serving a stale app shell - #12
Merged
Merged
Conversation
`index.html` went out with no `Cache-Control`, so browsers applied heuristic freshness and reused the cached shell without revalidating. Because Vite emits content-hashed filenames, that shell keeps naming the bundle it was built against: a rebuilt, restarted dashboard serves the old app indefinitely and nothing short of a hard reload discovers the new one. A live session showed only `/api/*` traffic -- the shell had last been fetched the day before. Send the no-store headers that the JSON routes already use with the shell, and mark `/assets/*` `immutable`: those names are content-hashed, so they can never go stale, which is what makes an uncacheable shell cheap. Fixing this also required repairing the SPA fallback it shares a code path with. StaticFiles signals a miss by *raising* Starlette's HTTPException rather than returning a 404, so the `if response.status_code != 404` branch was unreachable and client-side routes 404ed instead of serving the shell. Catch the exception instead -- Starlette's class, not FastAPI's subclass, which is what is raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StaticFiles` hands `get_response` a path normalized for the host OS, so on Windows it arrives as `assets\index-abc123.js` and the `assets/` prefix check never matched: the immutable header was silently dropped and the Windows CI jobs failed on it. Normalize separators before deciding. Going through `Path.as_posix()` looks tidier but only reads a backslash as a separator when running on Windows, which would have left the behaviour untestable anywhere else -- the parametrized test covers both separator styles on every platform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Targets
dev.Problem
index.htmlwas served with noCache-Control, so browsers applied heuristic freshness and reused the cached shell without revalidating. Vite emits content-hashed filenames, so a cached shell keeps naming the bundle it was built against — a rebuilt, restarted dashboard serves the old app indefinitely, and nothing short of a hard reload ever discovers the new one.The evidence, before the change:
A live session's log showed only
/api/*requests and the websocket — not oneGET /orGET /assets/…. The shell had last been fetched the previous day, which is why new features looked absent even though the shipped bundle contained them.Change
/,/index.html, and the client-side-route fallback)./assets/*getspublic, max-age=31536000, immutable. Those names are content-hashed and can never go stale, which is what keeps an uncacheable shell cheap: one small HTML fetch per load, everything else still served from cache.The fallback had to be repaired first
DashboardStaticFilesintended to serve the shell for client-side routes, but that code was unreachable:StaticFiles.get_responsesignals a miss by raising Starlette'sHTTPException, not by returning a 404, soif response.status_code != 404:never saw it and/topology404ed. Attaching headers to a dead branch would have done nothing, and the obvious test for it would have failed on the fallback rather than on caching.One subtlety worth flagging for review: it must catch Starlette's
HTTPException, not FastAPI's. FastAPI's subclasses Starlette's, so catching the FastAPI one silently misses what StaticFiles raises — I made exactly that mistake first and the fallback kept 404ing.Verification
Live, against a running graph server:
Cache-ControlGET /no-store, no-cache, must-revalidate, max-age=0GET /assets/index-CxDljRP7.jspublic, max-age=31536000, immutableGET /some/client/routeno-store, …Tests (
50 passed), four new ones intest_api_routes.pycovering the shell, client-side routes, immutable assets, and — importantly — that a missing hashed asset still 404s rather than quietly returning HTML, since the fallback repair makes that a real risk. ruff and format clean; packaged assets untouched, so the staleness check stays green.Not a regression, but worth knowing
curl -I(HEAD) against/api/*returns 404, because those routes are registered GET-only. That is identical ondevwithout this change — I checked both — and browsers use GET, so nothing here depends on it. Usecurl -sIfor the static paths and plaincurlfor the API when spot-checking.🤖 Generated with Claude Code