Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6757ad405d | |||
| 50e1a4aca5 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
- 修复版本:50e1a4ac
|
||||
|
||||
## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止
|
||||
|
||||
- 状态:resolved
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user