Skip to content

fix: SPA cache headers to prevent stale-shell blank page after deploy#30

Merged
study8677 merged 1 commit into
mainfrom
fix/spa-cache-headers
May 22, 2026
Merged

fix: SPA cache headers to prevent stale-shell blank page after deploy#30
study8677 merged 1 commit into
mainfrom
fix/spa-cache-headers

Conversation

@study8677
Copy link
Copy Markdown
Owner

Problem

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 → blank/white page.

Fix (src/opencmo/web/app.py, spa_catchall)

  • assets/* (Vite content-hashed) → Cache-Control: public, max-age=31536000, immutable
  • index.html shell + swappable static (logo, contact-qr) → Cache-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

…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 study8677 merged commit 2befdf8 into main May 22, 2026
3 checks passed
Copy link
Copy Markdown
Owner Author

@study8677 study8677 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总体评价

修复方向完全正确,精准解决了"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.tsbuild.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,但没有配套 ETagLast-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

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