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 {
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 失效」类错误 */
|
/** 判断是否为「发版后旧 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user