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"/); + } +});