Skip to content

fix: stop the browser serving a stale app shell - #12

Merged
cboulay merged 2 commits into
devfrom
fix/no-cache-app-shell
Aug 14, 2026
Merged

cboulay merged 2 commits into
devfrom
fix/no-cache-app-shell

Conversation

@cboulay

@cboulay cboulay commented Aug 14, 2026

Copy link
Copy Markdown
Member

Targets dev.

Problem

index.html was served with no Cache-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:

$ curl -sI http://127.0.0.1:<port>/
HTTP/1.1 200 OK
etag: "d21e2a59b5e664dc2cb88b4db27b5c40"
last-modified: ...
                      # no Cache-Control at all

A live session's log showed only /api/* requests and the websocket — not one GET / or GET /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

  • The shell gets the same no-store headers the JSON routes already use — on both paths that can serve it (/, /index.html, and the client-side-route fallback).
  • /assets/* gets public, 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

DashboardStaticFiles intended to serve the shell for client-side routes, but that code was unreachable: StaticFiles.get_response signals a miss by raising Starlette's HTTPException, not by returning a 404, so if response.status_code != 404: never saw it and /topology 404ed. 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:

request status Cache-Control
GET / 200 no-store, no-cache, must-revalidate, max-age=0
GET /assets/index-CxDljRP7.js 200 public, max-age=31536000, immutable
GET /some/client/route 200 (shell) no-store, …

Tests (50 passed), four new ones in test_api_routes.py covering 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 on dev without this change — I checked both — and browsers use GET, so nothing here depends on it. Use curl -sI for the static paths and plain curl for the API when spot-checking.

🤖 Generated with Claude Code

cboulay and others added 2 commits August 14, 2026 19:31
`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>
@cboulay
cboulay merged commit 242081b into dev Aug 14, 2026
11 checks passed
@cboulay
cboulay deleted the fix/no-cache-app-shell branch August 14, 2026 23:39
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.

1 participant