fix: 发版后软刷新检测入口壳并在 chunk 404 时硬刷新
避免 Logo/下拉刷新仍跑旧 index.js,以及站内消息预取失败弹出英文 toast。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,8 +40,9 @@ func SetupEmbed(r *gin.Engine) error {
|
||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||
fileServer := http.StripPrefix("/assets", http.FileServer(http.FS(sub)))
|
||||
r.GET("/assets/*filepath", func(c *gin.Context) {
|
||||
// hashed 资源可长期缓存;发版后文件名变更,旧 URL 自然 404
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
// 仅 200 写 immutable,避免中间层把旧 chunk 的 404 长期缓存
|
||||
w := &cacheOnOKWriter{ResponseWriter: c.Writer, cacheControl: "public, max-age=31536000, immutable"}
|
||||
c.Writer = w
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
@@ -57,6 +58,30 @@ func SetupEmbed(r *gin.Engine) error {
|
||||
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 入口(仅注入站点默认标题)
|
||||
func ServeSPA(c *gin.Context) {
|
||||
ServeSPAWithMeta(c, nil)
|
||||
|
||||
@@ -43,8 +43,9 @@ func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
||||
return
|
||||
}
|
||||
data = applySPAPageMeta(data, meta)
|
||||
// 入口 HTML 禁止长期缓存,否则发版后仍引用旧 chunk 哈希
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
// 入口 HTML 禁止缓存,否则发版后仍引用旧 chunk 哈希(部分反代对 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,18 @@ import { applyTheme, getStoredTheme } from './utils/theme';
|
||||
import App from './App';
|
||||
import { consumeHomeBoot } from './utils/homeBoot';
|
||||
import { beginHomeHydrate } from './utils/homeHydrate';
|
||||
import { reloadForStaleChunk } from './utils/chunkLoad';
|
||||
import { ensureColdBootReady, isMainLayoutPath } from './utils/prefetchRoute';
|
||||
|
||||
applyTheme(getStoredTheme());
|
||||
consumeHomeBoot();
|
||||
|
||||
// Vite modulepreload / 动态 import 失败(发版后旧 hashed URL 404)
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
event.preventDefault();
|
||||
reloadForStaleChunk();
|
||||
});
|
||||
|
||||
function hasSSRHome(): boolean {
|
||||
return !!document.querySelector('#root .ssr-home');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
const RELOAD_AT_KEY = 'j13:chunk-reload-at';
|
||||
const RELOAD_COOLDOWN_MS = 15_000;
|
||||
|
||||
/** 从 HTML 中提取入口 module script(Vite 产物:/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 失效」类错误 */
|
||||
export function isChunkLoadError(error: unknown): boolean {
|
||||
const msg = error instanceof Error
|
||||
@@ -32,3 +48,29 @@ export function reloadForStaleChunk(): boolean {
|
||||
window.location.reload();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
setCachedRecentUsers,
|
||||
setCachedTags,
|
||||
} from './layoutCache';
|
||||
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
|
||||
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
|
||||
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
|
||||
|
||||
@@ -57,28 +58,46 @@ function resolveUrl(to: To): URL {
|
||||
|
||||
/** 预加载对应路由的 lazy chunk(与 App.tsx lazyWithRetry 对齐) */
|
||||
function preloadChunk(pathname: string): Promise<unknown> {
|
||||
let load: Promise<unknown>;
|
||||
if (pathname === '/' || /^\/board\//.test(pathname)) {
|
||||
return import('../pages/HomePage');
|
||||
}
|
||||
if (/^\/post\/[^/]+\/edit$/.test(pathname) || pathname === '/compose') {
|
||||
return import('../pages/ComposePage');
|
||||
}
|
||||
if (/^\/post\//.test(pathname)) {
|
||||
return import('../pages/PostDetailPage');
|
||||
}
|
||||
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');
|
||||
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();
|
||||
}
|
||||
// 发版后旧 hashed URL 404:硬刷新,挂起 Promise 避免落到 toast
|
||||
return load.catch((err: unknown) => {
|
||||
if (isChunkLoadError(err) && reloadForStaleChunk()) {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
async function prefetchFeed(url: URL, force: boolean): Promise<void> {
|
||||
const limits = getCachedForumLimits();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isChunkLoadError, reloadForStaleChunk, reloadIfShellStale } from './chunkLoad';
|
||||
import { prefetchLayoutShell, prefetchRoute } from './prefetchRoute';
|
||||
import { doneTransition, startTransition } from './spaTransition';
|
||||
|
||||
@@ -10,20 +11,28 @@ export type SoftRefreshOpts = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 软刷新当前页:预热齐套后派发一次 commit。
|
||||
* 软刷新当前页:先检测入口壳是否过期,再预热齐套后派发一次 commit。
|
||||
* `progress: true` 时走顶栏进度条。
|
||||
*/
|
||||
export async function softRefreshCurrentPage(to?: string, opts?: SoftRefreshOpts): Promise<void> {
|
||||
const path = to ?? `${window.location.pathname}${window.location.search}`;
|
||||
const id = opts?.progress ? startTransition() : undefined;
|
||||
try {
|
||||
// 发版后旧壳仍在内存:先对比入口 script,过期则硬刷新
|
||||
if (await reloadIfShellStale()) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
prefetchRoute(path, { force: true }),
|
||||
prefetchLayoutShell({ force: true }),
|
||||
]);
|
||||
} catch {
|
||||
// 仍派发 commit,让界面有机会用已有缓存自愈
|
||||
} catch (e: unknown) {
|
||||
if (isChunkLoadError(e) && reloadForStaleChunk()) {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
|
||||
// 其它错误仍派发 commit,让界面有机会用已有缓存自愈
|
||||
} finally {
|
||||
if (id != null) doneTransition(id);
|
||||
}
|
||||
window.dispatchEvent(new Event(PAGE_SOFT_REFRESH_COMMIT_EVENT));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
|
||||
import { prefetchRoute } from './prefetchRoute';
|
||||
|
||||
type Listener = (active: boolean) => void;
|
||||
@@ -100,6 +101,8 @@ export async function transitionTo(
|
||||
nav(target, navOpts);
|
||||
} catch (e: unknown) {
|
||||
if (!silent && mySeq !== seq) return;
|
||||
// 发版后 chunk 404:整页刷新,不 toast 英文原错
|
||||
if (isChunkLoadError(e) && reloadForStaleChunk()) return;
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (id != null) {
|
||||
|
||||
Reference in New Issue
Block a user