fix: 发版后软刷新检测入口壳并在 chunk 404 时硬刷新

避免 Logo/下拉刷新仍跑旧 index.js,以及站内消息预取失败弹出英文 toast。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 07:53:39 +08:00
parent a02d103cfe
commit 37d14a22d5
7 changed files with 133 additions and 27 deletions

View File

@@ -40,8 +40,9 @@ func SetupEmbed(r *gin.Engine) error {
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil { if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub))) fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
r.GET("/assets/*filepath", func(c *gin.Context) { r.GET("/assets/*filepath", func(c *gin.Context) {
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404 // 仅 200 写 immutable避免中间层把旧 chunk 的 404 长期缓存
c.Header("Cache-Control", "public, max-age=31536000, immutable") w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
c.Writer = w
fileServer.ServeHTTP(c.Writer, c.Request) fileServer.ServeHTTP(c.Writer, c.Request)
}) })
} }
@@ -57,6 +58,30 @@ func SetupEmbed(r *gin.Engine) error {
return nil return nil
} }
// cacheOnOKWriter 仅在最终状态码为 200 时写入长期 Cache-Control
type cacheOnOKWriter struct {
gin.ResponseWriter
cacheControl string
wroteHeader bool
}
func (w *cacheOnOKWriter) WriteHeader(code int) {
if !w.wroteHeader {
w.wroteHeader = true
if code == http.StatusOK {
w.Header().Set("Cache-Control", w.cacheControl)
}
}
w.ResponseWriter.WriteHeader(code)
}
func (w *cacheOnOKWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题) // ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
func ServeSPA(c *gin.Context) { func ServeSPA(c *gin.Context) {
ServeSPAWithMeta(c, nil) ServeSPAWithMeta(c, nil)

View File

@@ -43,8 +43,9 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
return return
} }
data = applySPAPageMeta(data, meta) data = applySPAPageMeta(data, meta)
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希 // 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 no-cache 仍会存)
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Data(status, "text/html; charset=utf-8", data) c.Data(status, "text/html; charset=utf-8", data)
} }

View File

@@ -4,11 +4,18 @@ import { applyTheme, getStoredTheme } from './utils/theme';
import App from './App'; import App from './App';
import { consumeHomeBoot } from './utils/homeBoot'; import { consumeHomeBoot } from './utils/homeBoot';
import { beginHomeHydrate } from './utils/homeHydrate'; import { beginHomeHydrate } from './utils/homeHydrate';
import { reloadForStaleChunk } from './utils/chunkLoad';
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute'; import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
applyTheme(getStoredTheme()); applyTheme(getStoredTheme());
consumeHomeBoot(); consumeHomeBoot();
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
reloadForStaleChunk();
});
function hasSSRHome(): boolean { function hasSSRHome(): boolean {
return !!document.querySelector('#root .ssr-home'); return !!document.querySelector('#root .ssr-home');
} }

View File

@@ -2,6 +2,22 @@
const RELOAD_AT_KEY = 'j13:chunk-reload-at'; const RELOAD_AT_KEY = 'j13:chunk-reload-at';
const RELOAD_COOLDOWN_MS = 15_000; const RELOAD_COOLDOWN_MS = 15_000;
/** 从 HTML 中提取入口 module scriptVite 产物:/assets/index-*.js */
function extractEntryScriptSrc(html: string): string | null {
const re = /<script[^>]*\stype=["']module["'][^>]*\ssrc=["']([^"']+)["'][^>]*>/i;
const m = html.match(re);
if (m?.[1]) return m[1];
// src 在 type 之前
const re2 = /<script[^>]*\ssrc=["']([^"']+)["'][^>]*\stype=["']module["'][^>]*>/i;
return html.match(re2)?.[1] ?? null;
}
/** 当前文档已加载的入口 script src */
export function getDocumentEntryScriptSrc(): string | null {
const el = document.querySelector<HTMLScriptElement>('script[type="module"][src]');
return el?.getAttribute('src') ?? null;
}
/** 判断是否为「发版后旧 JS chunk 失效」类错误 */ /** 判断是否为「发版后旧 JS chunk 失效」类错误 */
export function isChunkLoadError(error: unknown): boolean { export function isChunkLoadError(error: unknown): boolean {
const msg = error instanceof Error const msg = error instanceof Error
@@ -32,3 +48,29 @@ export function reloadForStaleChunk(): boolean {
window.location.reload(); window.location.reload();
return true; return true;
} }
/**
* 软刷新前对比入口 HTML 中的 index-*.js 哈希。
* 发版后当前页 chunk 仍在内存,裸 import() 不会重新拉文件,必须主动检测。
* @returns 是否已触发硬刷新(调用方应停止后续软刷新)
*/
export async function reloadIfShellStale(): Promise<boolean> {
const current = getDocumentEntryScriptSrc();
if (!current) return false;
try {
const res = await fetch(`${window.location.pathname}${window.location.search}`, {
cache: 'no-store',
headers: { Accept: 'text/html' },
credentials: 'same-origin',
});
if (!res.ok) return false;
const html = await res.text();
const next = extractEntryScriptSrc(html);
if (!next || next === current) return false;
return reloadForStaleChunk();
} catch {
// 网络失败:跳过,继续软刷新
return false;
}
}

View File

@@ -26,6 +26,7 @@ import {
setCachedRecentUsers, setCachedRecentUsers,
setCachedTags, setCachedTags,
} from './layoutCache'; } from './layoutCache';
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
import { parsePermalinkID, parsePermalinkSlug } from './permalink'; import { parsePermalinkID, parsePermalinkSlug } from './permalink';
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache'; import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
@@ -57,27 +58,45 @@ function resolveUrl(to: To): URL {
/** 预加载对应路由的 lazy chunk与 App.tsx lazyWithRetry 对齐) */ /** 预加载对应路由的 lazy chunk与 App.tsx lazyWithRetry 对齐) */
function preloadChunk(pathname: string): Promise<unknown> { function preloadChunk(pathname: string): Promise<unknown> {
let load: Promise<unknown>;
if (pathname === '/' || /^\/board\//.test(pathname)) { if (pathname === '/' || /^\/board\//.test(pathname)) {
return import('../pages/HomePage'); load = import('../pages/HomePage');
} else if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') {
load = import('../pages/ComposePage');
} else if (/^\/post\//.test(pathname)) {
load = import('../pages/PostDetailPage');
} else if (pathname === '/profile') {
load = import('../pages/ProfilePage');
} else if (/^\/user\//.test(pathname)) {
load = import('../pages/UserProfilePage');
} else if (pathname === '/favorites') {
load = import('../pages/FavoritesPage');
} else if (pathname === '/projects') {
load = import('../pages/ProjectsPage');
} else if (pathname === '/links') {
load = import('../pages/LinksPage');
} else if (pathname === '/showcase') {
load = import('../pages/ShowcasePage');
} else if (pathname === '/messages') {
load = import('../pages/MessagesPage');
} else if (/^\/page\//.test(pathname)) {
load = import('../pages/SitePageView');
} else if (pathname === '/login') {
load = import('../pages/LoginPage');
} else if (pathname === '/register') {
load = import('../pages/RegisterPage');
} else if (pathname === '/forgot-password') {
load = import('../pages/ForgotPasswordPage');
} else {
return Promise.resolve();
} }
if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') { // 发版后旧 hashed URL 404硬刷新挂起 Promise 避免落到 toast
return import('../pages/ComposePage'); return load.catch((err: unknown) => {
} if (isChunkLoadError(err) && reloadForStaleChunk()) {
if (/^\/post\//.test(pathname)) { return new Promise(() => {});
return import('../pages/PostDetailPage'); }
} throw err;
if (pathname === '/profile') return import('../pages/ProfilePage'); });
if (/^\/user\//.test(pathname)) return import('../pages/UserProfilePage');
if (pathname === '/favorites') return import('../pages/FavoritesPage');
if (pathname === '/projects') return import('../pages/ProjectsPage');
if (pathname === '/links') return import('../pages/LinksPage');
if (pathname === '/showcase') return import('../pages/ShowcasePage');
if (pathname === '/messages') return import('../pages/MessagesPage');
if (/^\/page\//.test(pathname)) return import('../pages/SitePageView');
if (pathname === '/login') return import('../pages/LoginPage');
if (pathname === '/register') return import('../pages/RegisterPage');
if (pathname === '/forgot-password') return import('../pages/ForgotPasswordPage');
return Promise.resolve();
} }
async function prefetchFeed(url: URL, force: boolean): Promise<void> { async function prefetchFeed(url: URL, force: boolean): Promise<void> {

View File

@@ -1,3 +1,4 @@
import { isChunkLoadError, reloadForStaleChunk, reloadIfShellStale } from './chunkLoad';
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute'; import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
import { doneTransition, startTransition } from './spaTransition'; import { doneTransition, startTransition } from './spaTransition';
@@ -10,20 +11,28 @@ export type SoftRefreshOpts = {
}; };
/** /**
* 软刷新当前页:预热齐套后派发一次 commit。 * 软刷新当前页:先检测入口壳是否过期,再预热齐套后派发一次 commit。
* `progress: true` 时走顶栏进度条。 * `progress: true` 时走顶栏进度条。
*/ */
export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> { export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> {
const path = to ?? `${window.location.pathname}${window.location.search}`; const path = to ?? `${window.location.pathname}${window.location.search}`;
const id = opts?.progress ? startTransition() : undefined; const id = opts?.progress ? startTransition() : undefined;
try { try {
// 发版后旧壳仍在内存:先对比入口 script过期则硬刷新
if (await reloadIfShellStale()) {
return;
}
await Promise.all([ await Promise.all([
prefetchRoute(path, { force: true }), prefetchRoute(path, { force: true }),
prefetchLayoutShell({ force: true }), prefetchLayoutShell({ force: true }),
]); ]);
} catch { } catch (e: unknown) {
// 仍派发 commit让界面有机会用已有缓存自愈 if (isChunkLoadError(e) && reloadForStaleChunk()) {
return;
}
// 其它错误仍派发 commit让界面有机会用已有缓存自愈
} finally {
if (id != null) doneTransition(id);
} }
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT)); window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
if (id != null) doneTransition(id);
} }

View File

@@ -1,5 +1,6 @@
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'; import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
import { prefetchRoute } from './prefetchRoute'; import { prefetchRoute } from './prefetchRoute';
type Listener = (active: boolean) => void; type Listener = (active: boolean) => void;
@@ -100,6 +101,8 @@ export async function transitionTo(
nav(target, navOpts); nav(target, navOpts);
} catch (e: unknown) { } catch (e: unknown) {
if (!silent && mySeq !== seq) return; if (!silent && mySeq !== seq) return;
// 发版后 chunk 404整页刷新不 toast 英文原错
if (isChunkLoadError(e) && reloadForStaleChunk()) return;
notify.error(e instanceof Error ? e.message : '加载失败'); notify.error(e instanceof Error ? e.message : '加载失败');
} finally { } finally {
if (id != null) { if (id != null) {