fix: SPA cache headers to prevent stale-shell blank page after deploy#30
Merged
Conversation
…ploy The SPA shell (index.html) was served with no Cache-Control header, so browsers cached it heuristically. After a deploy renames the content-hashed JS/CSS chunks (and rsync --delete removes the old ones), a cached old shell points at now-404 assets and React never boots — a blank/white page. Serve the shell and swappable static files (logo, contact-qr) with `no-cache` (always revalidate), and the content-hashed `assets/` files with `immutable, max-age=1y` so they still cache long-term. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
study8677
commented
May 22, 2026
Owner
Author
study8677
left a comment
There was a problem hiding this comment.
总体评价
修复方向完全正确,精准解决了"rsync --delete 删旧 chunk → 旧 shell 指向 404 资产 → React 白屏"的经典部署问题。代码改动小而聚焦,风险低。建议合并(已合并),但有两个值得跟进的改进点。
问题清单
| 级别 | 文件 & 行号 | 描述 | 建议 |
|---|---|---|---|
| 🟡 建议 | tests/test_web.py:1873 |
test_spa_catchall 测试了 /assets/main.js 返回 200,但未断言 Cache-Control: public, max-age=31536000, immutable;根路由 / 也未断言 Cache-Control: no-cache。PR 描述声称"Verified (TestClient)",却没有提交对应的断言,回归保护缺失——下次有人改动 spa_catchall 逻辑时,缓存头静默失效也不会被 CI 发现。 |
在现有 test_spa_catchall 内补充两条断言(见修改示例)。 |
| 🟡 建议 | app.py:1873 |
"assets/" 是与 Vite build.assetsDir(默认值)耦合的魔法字符串。若 vite.config.ts 将 build.assetsDir 改为其他值,缓存策略会静默失效,content-hashed 文件将被当成普通文件走 no-cache。 |
提取为模块级常量 _VITE_ASSETS_PREFIX = "assets/" 并注释其与 vite.config.ts 的绑定关系,方便未来同步修改。 |
| 🟢 优化 | app.py:1876 |
open(asset, "rb") 是同步 I/O,在 asyncio 事件循环中会造成短暂阻塞(预存问题,非本 PR 引入)。 |
后续可改为 aiofiles.open(asset, "rb") 或用 asyncio.get_event_loop().run_in_executor(None, ...) 包裹,避免 Starlette worker 被卡住。 |
| 🟢 优化 | app.py:1894 |
HTMLResponse 返回时设置了 Cache-Control: no-cache,但没有配套 ETag 或 Last-Modified 头。no-cache 语义是"缓存后必须先向服务器验证再使用",但缺少条件请求头时浏览器只能发全量请求,服务器也只能回 200(而非 304 Not Modified),达不到带宽节省的效果。 |
效果上已完全正确(每次取最新 shell,无白屏);若未来想进一步优化,可在 HTMLResponse 上手动加 ETag: sha256(rendered_html)[:16] 并处理 If-None-Match,但优先级不高。 |
亮点
- 根因分析准确:PR 描述清楚地点出了"启发式缓存 + rsync --delete 删旧 chunk = 白屏"的因果链,比只写"fix cache"更有参考价值。
immutable策略是最佳实践:内容哈希文件 +immutable可让浏览器完全跳过 304 协商,CDN 也能无限缓存,性能收益最大化。- 最小改动原则:仅修改
spa_catchall的返回路径,不影响认证、API、SSE 等其他路由,风险可控。 - 注释有价值:两处注释都解释了 WHY(内容哈希不变性、防止 stale shell),符合"仅在 WHY 不明显时写注释"的原则。
修改示例
补充缓存头断言(tests/test_web.py)
def test_spa_catchall(client, tmp_path):
"""SPA routes serve index.html when dist exists."""
spa_dir = tmp_path / "spa_dist"
spa_dir.mkdir()
(spa_dir / "index.html").write_text("<html>SPA</html>")
assets_dir = spa_dir / "assets"
assets_dir.mkdir()
(assets_dir / "main.js").write_text("console.log('hi')")
with patch.object(app_module, "_SPA_DIR", spa_dir):
# SPA shell must be revalidated every time (no stale-shell blank page)
resp = client.get("/")
assert resp.status_code == 200
assert resp.headers.get("Cache-Control") == "no-cache" # ← 新增
resp = client.get("/projects/1")
assert resp.status_code == 200
assert resp.headers.get("Cache-Control") == "no-cache" # ← 新增
# Content-hashed assets must be cached forever
resp = client.get("/assets/main.js")
assert resp.status_code == 200
assert "immutable" in resp.headers.get("Cache-Control", "") # ← 新增
# Non-hashed static files (logo etc.) revalidate
(spa_dir / "logo.png").write_bytes(b"\x89PNG")
resp = client.get("/logo.png")
assert resp.status_code == 200
assert resp.headers.get("Cache-Control") == "no-cache" # ← 新增提取魔法字符串(src/opencmo/web/app.py)
# Must stay in sync with vite.config.ts `build.assetsDir` (default: "assets")
_VITE_ASSETS_PREFIX = "assets/"
# 在 spa_catchall 内:
cache_control = (
"public, max-age=31536000, immutable"
if full_path.startswith(_VITE_ASSETS_PREFIX)
else "no-cache"
)Generated by Claude Code
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.
Problem
The SPA shell (
index.html) was served with noCache-Controlheader, so browsers cached it heuristically. After a deploy renames the content-hashed JS/CSS chunks (andrsync --deleteremoves the old ones), a cached old shell points at now-404 assets and React never boots → blank/white page.Fix (
src/opencmo/web/app.py,spa_catchall)assets/*(Vite content-hashed) →Cache-Control: public, max-age=31536000, immutableCache-Control: no-cache(always revalidate)Verified (TestClient)
/,/console→ 200,no-cache/assets/index-*.js|.css→ 200,immutable/logo.png,/contact-qr.png→ 200,no-cache