Version static assets so a fixed bug stops reproducing in an open tab
The trendline slope fix was already correct — measured on the live chart, the two rendered segments came out at screen slopes 0.4910 and 0.4903, collinear. It still reproduced in the browser because the browser was not running it. StaticFiles sends an ETag but no Cache-Control, and the asset URLs carried no version, so nothing forced a refetch. A tab left open across an edit never fetches at all: it keeps executing the JavaScript it loaded when the page was first opened. Every fix since that tab was opened was invisible in it. The page now stamps its own asset URLs with a digest of their contents and is itself served no-store. Hashing rather than stamping mtimes, because a deploy checks every file out fresh and would otherwise invalidate assets that never changed. This also removes a trap the README already half-documented for deploys: an /api-only change leaves the HTML byte-identical, and until now a JavaScript change could leave the served page byte-identical too. The debug handle used to measure the geometry is kept deliberately. Chart rendering bugs are invisible from the outside, and window.__chart.lineData() against timeToCoordinate() is what settled this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2efcec6a76
commit
e69d1c190f
3 changed files with 65 additions and 2 deletions
31
main.py
31
main.py
|
|
@ -3,8 +3,11 @@ import asyncio
|
|||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import re
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.api.meta import router as meta_router
|
||||
|
|
@ -38,6 +41,30 @@ app.include_router(api_router)
|
|||
app.include_router(ws_router)
|
||||
|
||||
|
||||
ASSET_REF = re.compile(r'((?:src|href)="/static/[^"?]+)"')
|
||||
|
||||
|
||||
def asset_version() -> str:
|
||||
"""A digest of the served assets, so the URL changes iff the content does.
|
||||
|
||||
StaticFiles sends an ETag but no Cache-Control, so a browser is free to keep
|
||||
using the copy it already has — and a tab left open simply never fetches
|
||||
again. That turned a fixed bug into a bug that still reproduced, because the
|
||||
page was running the JavaScript it had loaded hours earlier.
|
||||
|
||||
Hashing rather than stamping mtimes: a deploy checks every file out fresh,
|
||||
which would otherwise invalidate assets that never changed.
|
||||
"""
|
||||
digest = sha256()
|
||||
for path in sorted(STATIC_DIR.glob("*.*")):
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()[:12]
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
html = ASSET_REF.sub(rf'\1?v={asset_version()}"', html)
|
||||
# The document itself must never be cached, or the versioned URLs inside it
|
||||
# are the stale thing instead.
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
|
||||
|
|
|
|||
|
|
@ -418,6 +418,10 @@ createApp({
|
|||
|
||||
onMounted(() => {
|
||||
chartApi = new ConfluenceChart();
|
||||
// Deliberate debug handle. Chart geometry bugs are invisible from the
|
||||
// outside — this is how the trendline slope was measured rather than
|
||||
// guessed at: __chart.lineData(level) against timeToCoordinate().
|
||||
window.__chart = chartApi;
|
||||
chartApi.create(document.getElementById('chart'));
|
||||
chartApi.setClickHandler(handleChartClick);
|
||||
chartApi.setToolCompleteHandler(handleToolComplete);
|
||||
|
|
|
|||
32
tests/test_asset_versioning.py
Normal file
32
tests/test_asset_versioning.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import main
|
||||
|
||||
|
||||
def test_assets_are_versioned_and_the_page_is_not_cached():
|
||||
# Not a context manager: that would run the lifespan, which seeds months of
|
||||
# history from Yahoo before serving anything.
|
||||
response = TestClient(main.app).get("/")
|
||||
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
refs = re.findall(r'(?:src|href)="(/static/[^"]+)"', response.text)
|
||||
assert refs, "no static assets referenced"
|
||||
# Without this a tab left open keeps running the JavaScript it first loaded,
|
||||
# which made a fixed bug look like it was still broken.
|
||||
assert all("?v=" in ref for ref in refs), refs
|
||||
|
||||
|
||||
def test_version_tracks_content_not_timestamps(tmp_path, monkeypatch):
|
||||
asset = tmp_path / "app.js"
|
||||
asset.write_text("//", encoding="utf-8")
|
||||
monkeypatch.setattr(main, "STATIC_DIR", tmp_path)
|
||||
|
||||
first = main.asset_version()
|
||||
asset.touch()
|
||||
# A deploy checks every file out fresh; unchanged assets must stay cached.
|
||||
assert main.asset_version() == first
|
||||
|
||||
asset.write_text("// changed", encoding="utf-8")
|
||||
assert main.asset_version() != first
|
||||
Loading…
Reference in a new issue