fix(web): reload stale clients instead of spinning after a release

Homepage bootstrap never escaped the loading shell when a new web image changed chunk hashes. Stamp deploymentId, stop caching chat HTML, and hard-reload failed chunk loads once.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 10:29:43 +08:00
parent a5eaba7a26
commit 50e1a4aca5
12 changed files with 226 additions and 2 deletions
+4
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+16
View File
@@ -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
+13
View File
@@ -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,
+4
View File
@@ -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
)}
</head>
<body>
<StaleClientRecovery />
{children}
<Toaster />
</body>
+8 -1
View File
@@ -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);
}
@@ -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;
}
+25
View File
@@ -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<Storage, "getItem" | "setItem">): 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<Storage, "removeItem">): 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;
}
+2 -1
View File
@@ -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", () => {
@@ -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\(\)/);
@@ -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<string, string>();
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"/);
}
});