diff --git a/deploy/Caddyfile b/deploy/Caddyfile
index baca242f..c94c8a24 100644
--- a/deploy/Caddyfile
+++ b/deploy/Caddyfile
@@ -1,5 +1,9 @@
{$SITE_ADDRESS:https://jyotisha.chat} {
encode zstd gzip
+
+ @htmlDocuments path / /login
+ header @htmlDocuments Cache-Control "private, no-store, must-revalidate"
+
reverse_proxy web:3000 {
lb_try_duration 10s
lb_try_interval 250ms
diff --git a/deploy/Caddyfile.production.selfhosted b/deploy/Caddyfile.production.selfhosted
index a03e91b5..a4662ac1 100644
--- a/deploy/Caddyfile.production.selfhosted
+++ b/deploy/Caddyfile.production.selfhosted
@@ -1,6 +1,9 @@
{$SITE_ADDRESS:https://jyotisha.chat} {
encode zstd gzip
+ @htmlDocuments path / /login
+ header @htmlDocuments Cache-Control "private, no-store, must-revalidate"
+
@adminPaths path /admin /admin/* /api/admin/*
respond @adminPaths "Not found" 404
diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging
index 6a79a92c..cb31c654 100644
--- a/deploy/Caddyfile.staging
+++ b/deploy/Caddyfile.staging
@@ -1,6 +1,9 @@
{$SITE_ADDRESS:https://staging.jyotisha.chat} {
encode zstd gzip
+ @htmlDocuments path / /login
+ header @htmlDocuments Cache-Control "private, no-store, must-revalidate"
+
@adminPaths path /admin /admin/* /api/admin/*
respond @adminPaths "Not found" 404
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index fbd18f81..2e86d6a4 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -5083,6 +5083,22 @@
- 复发自:BUG-323(把确认宽度写进提出门);BUG-297(并列分钟应给出代表性时间,而不是继续当收集失败)
- 修复版本:a88467ff
+## BUG-338 | 发布新版本后,旧网页停在「正在载入账户」转圈进不去
+
+- 状态:resolved
+- 首次发现:2026-08-21
+- 最近更新:2026-08-21
+- 影响面:首页 bootstrap、`/` / `/login` HTML 缓存、Next `deploymentId`、发布后已打开的旧标签页
+- 用户现象:旧聊天页或已打开的网页,在 push 新版本之后一直停在「正在载入账户 / 同步个人资料与对话记录」,进不到对话。Safari 可能同时提示降低高级隐私保护。
+- 触发条件:Web 镜像切换后,用户仍使用上一发布的 HTML/JS(后台冻住的标签、浏览器缓存的首页,或 401 跳转登录页时新 chunk 404)。
+- 根因:首页 SSR 在 `hydrated` 完成前就画引导动画。新镜像的 `/_next/static` 文件名已变,旧客户端加载失败后 hydration 和 8 秒超时都不跑。`next.config.ts` 未设置 `deploymentId`,HTML 也可被浏览器缓存。账户 401 会 `replace("/login")` 并清掉超时、不写 `accountError`,登录页脚本同样 404 时界面条件仍是「没账户也没错误」,继续转圈。
+- 修复:构建时把 Git SHA 写入 Next `deploymentId`,让跨发布客户端导航整页重载。`/` 与 `/login` 响应 `Cache-Control: private, no-store`。根 layout 对 chunk 加载失败和冻在引导层的 pageshow 做一次硬刷新。401 跳转不再清掉 bootstrap 超时,登录没走成时落到可重试错误页。成功载入后清掉刷新标记,避免死循环。
+- 验证:`frontend/tests/stale-client-recovery.test.ts`;`frontend/tests/membership-page.test.ts`;`frontend/tests/staging-backend-workflows.test.ts`。
+- 防复发:Web 镜像必须在 `next build` 时注入 `NEXT_DEPLOYMENT_ID` 且 `next.config.ts` 写入 `deploymentId`。首页 HTML 不得长期缓存。引导动画不得在「无账户且无错误」时成为发布失败的终态。
+- 相关记录:BUG-160、BUG-204
+- 复发自:BUG-204(报告页 chunk 偏斜已修,首页引导层仍会把同一失败画成永久 loading)
+- 修复版本:待提交
+
## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止
- 状态:resolved
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index bb1af318..869f9f57 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -4,6 +4,19 @@ import type { NextConfig } from "next";
const repositoryRoot = path.join(process.cwd(), "..");
const nextConfig: NextConfig = {
+ deploymentId: process.env.NEXT_DEPLOYMENT_ID,
+ async headers() {
+ return [
+ {
+ source: "/",
+ headers: [{ key: "Cache-Control", value: "private, no-store, must-revalidate" }],
+ },
+ {
+ source: "/login",
+ headers: [{ key: "Cache-Control", value: "private, no-store, must-revalidate" }],
+ },
+ ];
+ },
devIndicators: false,
experimental: {
authInterrupts: true,
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index acec6471..58cb79ca 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -1,9 +1,12 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
+import { StaleClientRecovery } from "@/components/stale-client-recovery";
import "./globals.css";
import "./birth-time-choice.css";
+export const dynamic = "force-dynamic";
+
export const metadata: Metadata = {
title: "Jyotisha · 印度占星",
description: "与 Mastra Agent 对话,基于星盘证据讨论事业、关系与时间窗口。",
@@ -39,6 +42,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
)}
+
{children}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index cff7d144..bcae121b 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -37,6 +37,7 @@ import { BirthPlacePicker } from "@/components/birth-place-picker";
import { ChatComposer } from "@/components/chat-composer";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
+import { clearStaleClientReload } from "@/lib/stale-client-recovery";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, resolveSessionTitle, type ReplyTheme } from "@/lib/agent-reply";
import {
@@ -1411,6 +1412,7 @@ export default function Home() {
}, 8000);
async function loadCloudData() {
+ let redirectedToLogin = false;
try {
const previewMode = process.env.NODE_ENV === "development"
? new URLSearchParams(window.location.search).get("preview")
@@ -1577,6 +1579,7 @@ export default function Home() {
}
if (controller.signal.aborted) return;
+ clearStaleClientReload(sessionStorage);
const nextProfile = readProfile(nextAccount.profile);
setAccount(nextAccount);
setModelCatalog(nextModelCatalog);
@@ -1609,11 +1612,15 @@ export default function Home() {
}
}
} catch (caught) {
- if (caught instanceof LoginRedirectError) return;
+ if (caught instanceof LoginRedirectError) {
+ redirectedToLogin = true;
+ return;
+ }
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
}
} finally {
+ if (redirectedToLogin) return;
window.clearTimeout(bootstrapTimeout);
if (!controller.signal.aborted) setHydrated(true);
}
diff --git a/frontend/src/components/stale-client-recovery.tsx b/frontend/src/components/stale-client-recovery.tsx
new file mode 100644
index 00000000..7c8d82c5
--- /dev/null
+++ b/frontend/src/components/stale-client-recovery.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import { useEffect } from "react";
+
+import {
+ consumeStaleClientReload,
+ isChunkLoadFailure,
+ shouldReloadFrozenBootstrap,
+} from "@/lib/stale-client-recovery";
+
+function reloadIfStale() {
+ try {
+ if (!consumeStaleClientReload(sessionStorage)) return;
+ } catch {
+ return;
+ }
+ window.location.reload();
+}
+
+export function StaleClientRecovery() {
+ useEffect(() => {
+ const onError = (event: ErrorEvent) => {
+ const message = `${event.message} ${event.filename ?? ""} ${event.error instanceof Error ? event.error.message : ""}`;
+ if (isChunkLoadFailure(message)) reloadIfStale();
+ };
+ const onRejection = (event: PromiseRejectionEvent) => {
+ const reason = event.reason;
+ const message = reason instanceof Error ? reason.message : String(reason ?? "");
+ if (isChunkLoadFailure(message)) reloadIfStale();
+ };
+ const onPageShow = (event: PageTransitionEvent) => {
+ if (shouldReloadFrozenBootstrap({
+ persisted: event.persisted,
+ loadingBusy: Boolean(document.querySelector('main.app-loading[aria-busy="true"]')),
+ loadingError: Boolean(document.querySelector("main.app-loading-error")),
+ })) {
+ reloadIfStale();
+ }
+ };
+ window.addEventListener("error", onError);
+ window.addEventListener("unhandledrejection", onRejection);
+ window.addEventListener("pageshow", onPageShow);
+ return () => {
+ window.removeEventListener("error", onError);
+ window.removeEventListener("unhandledrejection", onRejection);
+ window.removeEventListener("pageshow", onPageShow);
+ };
+ }, []);
+ return null;
+}
diff --git a/frontend/src/lib/stale-client-recovery.ts b/frontend/src/lib/stale-client-recovery.ts
new file mode 100644
index 00000000..6a8aea4f
--- /dev/null
+++ b/frontend/src/lib/stale-client-recovery.ts
@@ -0,0 +1,25 @@
+export const STALE_CLIENT_RELOAD_KEY = "jyotisha:stale-client-reload";
+
+const CHUNK_FAILURE = /ChunkLoadError|Loading chunk .+ failed|Failed to fetch dynamically imported module|error loading dynamically imported module|_next\/static\//i;
+
+export function isChunkLoadFailure(message: string): boolean {
+ return CHUNK_FAILURE.test(message);
+}
+
+export function consumeStaleClientReload(storage: Pick): boolean {
+ if (storage.getItem(STALE_CLIENT_RELOAD_KEY) === "1") return false;
+ storage.setItem(STALE_CLIENT_RELOAD_KEY, "1");
+ return true;
+}
+
+export function clearStaleClientReload(storage: Pick): void {
+ storage.removeItem(STALE_CLIENT_RELOAD_KEY);
+}
+
+export function shouldReloadFrozenBootstrap(input: {
+ persisted: boolean;
+ loadingBusy: boolean;
+ loadingError: boolean;
+}): boolean {
+ return input.persisted && input.loadingBusy && !input.loadingError;
+}
diff --git a/frontend/tests/membership-page.test.ts b/frontend/tests/membership-page.test.ts
index ed031681..ff6bbcea 100644
--- a/frontend/tests/membership-page.test.ts
+++ b/frontend/tests/membership-page.test.ts
@@ -101,7 +101,8 @@ test("home refreshes the account whenever it is shown again", () => {
test("login redirects keep the loading screen instead of flashing the account error page", () => {
assert.match(homePageSource, /class LoginRedirectError extends Error/);
assert.match(homePageSource, /window\.location\.replace\("\/login"\)/);
- assert.match(homePageSource, /if \(caught instanceof LoginRedirectError\) return;/);
+ assert.match(homePageSource, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/);
+ assert.match(homePageSource, /if \(redirectedToLogin\) return;/);
});
test("homepage composer does not render notice copy", () => {
diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts
index 28702463..a44555de 100644
--- a/frontend/tests/staging-backend-workflows.test.ts
+++ b/frontend/tests/staging-backend-workflows.test.ts
@@ -171,6 +171,7 @@ test("railway web image uses Next standalone runtime output", () => {
assert.equal(packageJson.scripts?.build, "next build");
assert.match(config, /output: "standalone"/);
+ assert.match(config, /deploymentId: process.env.NEXT_DEPLOYMENT_ID/);
assert.match(config, /outputFileTracingRoot: repositoryRoot/);
assert.match(startupGuard, /process\.env\.NEXT_RUNTIME !== "nodejs"/);
assert.match(startupGuard, /verifyAllActiveSkillPackages\(\)/);
diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts
new file mode 100644
index 00000000..457f73d6
--- /dev/null
+++ b/frontend/tests/stale-client-recovery.test.ts
@@ -0,0 +1,97 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+import {
+ STALE_CLIENT_RELOAD_KEY,
+ clearStaleClientReload,
+ consumeStaleClientReload,
+ isChunkLoadFailure,
+ shouldReloadFrozenBootstrap,
+} from "../src/lib/stale-client-recovery.ts";
+
+const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
+const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
+const recoverySource = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
+const stagingCaddy = readFileSync(new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8");
+const productionCaddy = readFileSync(new URL("../../deploy/Caddyfile.production.selfhosted", import.meta.url), "utf8");
+const publicCaddy = readFileSync(new URL("../../deploy/Caddyfile", import.meta.url), "utf8");
+
+test("chunk load and missing Next static files are treated as a stale client", () => {
+ assert.equal(isChunkLoadFailure("Loading chunk 123 failed"), true);
+ assert.equal(isChunkLoadFailure("ChunkLoadError: Loading chunk app/page failed"), true);
+ assert.equal(isChunkLoadFailure("Failed to fetch dynamically imported module: https://staging.jyotisha.chat/_next/static/chunks/app/page.js"), true);
+ assert.equal(isChunkLoadFailure("https://staging.jyotisha.chat/_next/static/chunks/main-app.js"), true);
+ assert.equal(isChunkLoadFailure("暂时无法读取账户信息"), false);
+});
+
+test("a stale client reloads once per tab and can retry after a successful bootstrap", () => {
+ const storage = new Map();
+ const adapter = {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ storage.set(key, value);
+ },
+ removeItem: (key: string) => {
+ storage.delete(key);
+ },
+ };
+ assert.equal(consumeStaleClientReload(adapter), true);
+ assert.equal(storage.get(STALE_CLIENT_RELOAD_KEY), "1");
+ assert.equal(consumeStaleClientReload(adapter), false);
+ clearStaleClientReload(adapter);
+ assert.equal(consumeStaleClientReload(adapter), true);
+});
+
+test("a frozen loading shell on pageshow is reloaded, but the error screen is not", () => {
+ assert.equal(shouldReloadFrozenBootstrap({
+ persisted: true,
+ loadingBusy: true,
+ loadingError: false,
+ }), true);
+ assert.equal(shouldReloadFrozenBootstrap({
+ persisted: false,
+ loadingBusy: true,
+ loadingError: false,
+ }), false);
+ assert.equal(shouldReloadFrozenBootstrap({
+ persisted: true,
+ loadingBusy: true,
+ loadingError: true,
+ }), false);
+});
+
+test("the web build stamps the Git SHA as Next deploymentId and does not cache the chat HTML", () => {
+ assert.match(nextConfig, /deploymentId:\s*process\.env\.NEXT_DEPLOYMENT_ID/);
+ assert.match(nextConfig, /source: "\/"/);
+ assert.match(nextConfig, /source: "\/login"/);
+ assert.match(nextConfig, /Cache-Control["'],\s*value: "private, no-store, must-revalidate"/);
+ assert.match(layoutSource, /export const dynamic = "force-dynamic"/);
+ assert.match(layoutSource, /StaleClientRecovery/);
+});
+
+test("edge and layout recover a stale client without looping forever", () => {
+ assert.match(recoverySource, /consumeStaleClientReload/);
+ assert.match(recoverySource, /window\.location\.reload\(\)/);
+ assert.match(recoverySource, /pageshow/);
+ assert.match(recoverySource, /main\.app-loading\[aria-busy="true"\]/);
+ assert.match(pageSource, /clearStaleClientReload\(sessionStorage\)/);
+});
+
+test("a login redirect leaves the bootstrap timeout running so a failed navigation can escape the spinner", () => {
+ const bootstrap = pageSource.slice(
+ pageSource.indexOf("async function loadCloudData()"),
+ pageSource.indexOf("void loadCloudData();"),
+ );
+ assert.match(bootstrap, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/);
+ assert.match(bootstrap, /if \(redirectedToLogin\) return;/);
+ assert.match(bootstrap, /window\.clearTimeout\(bootstrapTimeout\)/);
+});
+
+test("user-facing Caddy files do not let browsers keep the chat HTML across releases", () => {
+ for (const caddy of [stagingCaddy, productionCaddy, publicCaddy]) {
+ assert.match(caddy, /@htmlDocuments path \/ \/login/);
+ assert.match(caddy, /header @htmlDocuments Cache-Control "private, no-store, must-revalidate"/);
+ }
+});