fix: 进度条触发时机提前到点击瞬间(覆盖全部导航)

- 根因:此前靠 history.pushState 触发,但 App Router 在导航提交期
  (数据加载完成后)才调用它——点击到提交之间的加载期进度条不出现;
  View Transitions 的旧页快照还会遮住提交后的一闪
- 修复:document 级 click 捕获(站内 <a> 点击瞬间触发,先于框架逻辑
  与 VT 快照)+ history API 兜底(popstate/程序化导航)+ 4s 超时保护
- 回归:顶部导航/footer 链接/语言切换(程序化导航)三类全部触发
This commit is contained in:
AI Directory Platform
2026-09-10 08:47:03 +00:00
parent 935062393c
commit c50b5a6c5e
@@ -5,9 +5,12 @@ import { usePathname, useSearchParams } from "next/navigation";
/** /**
* # 组件 NavigationProgress * # 组件 NavigationProgress
* 说明:顶部导航进度条。拦截 history API(pushState/replaceState/popstate) * 说明:顶部导航进度条。
* 触发进度条出现,pathname 变化后完成并淡出。 * - 触发:document 级 click 捕获(点击站内 <a> 的瞬间,先于框架逻辑与
* 纯客户端实现,零依赖;颜色走主题 token(深浅模式自动适配)。 * View Transitions 快照)+ history API 兜底(popstate / 程序化导航)。
* - 完成:pathname/search 变化后冲刺 100% 并淡出;带 4s 超时保护
* (导航被阻止时进度条不悬挂)。
* - 颜色走主题 token(深浅模式自动适配),零依赖。
*/ */
export function NavigationProgress() { export function NavigationProgress() {
const pathname = usePathname(); const pathname = usePathname();
@@ -15,22 +18,38 @@ export function NavigationProgress() {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [fading, setFading] = useState(false); const [fading, setFading] = useState(false);
const timers = useRef<{ slow?: ReturnType<typeof setTimeout>; done?: ReturnType<typeof setTimeout> }>({}); const timers = useRef<{ slow?: ReturnType<typeof setTimeout>; done?: ReturnType<typeof setTimeout>; guard?: ReturnType<typeof setTimeout> }>({});
// 拦截导航开始
useEffect(() => { useEffect(() => {
const start = () => { const start = () => {
setVisible(true); setVisible(true);
setFading(false); setFading(false);
setProgress(12); setProgress(12);
// 缓慢爬升到 70%,营造"进行中"
clearTimeout(timers.current.slow); clearTimeout(timers.current.slow);
timers.current.slow = setTimeout(() => setProgress(70), 180); timers.current.slow = setTimeout(() => setProgress(70), 180);
// 超时保护:导航未发生时自动收起
clearTimeout(timers.current.guard);
timers.current.guard = setTimeout(() => {
setFading(true);
setVisible(false);
setProgress(0);
}, 4000);
}; };
// ① 点击捕获:站内 <a> 点击瞬间触发(先于 next/link 与 View Transitions)
const onClickCapture = (e: MouseEvent) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const anchor = (e.target as HTMLElement).closest?.("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("//") || href.startsWith("#") || anchor.target === "_blank") return;
if (!href.startsWith("/")) return;
start();
};
// ② history 兜底:popstate / 程序化 router.push
const origPush = window.history.pushState.bind(window.history); const origPush = window.history.pushState.bind(window.history);
const origReplace = window.history.replaceState.bind(window.history); const origReplace = window.history.replaceState.bind(window.history);
window.history.pushState = function (...args: Parameters<typeof origPush>) { window.history.pushState = function (...args: Parameters<typeof origPush>) {
start(); start();
return origPush(...args); return origPush(...args);
@@ -40,18 +59,21 @@ export function NavigationProgress() {
return origReplace(...args); return origReplace(...args);
}; };
window.addEventListener("popstate", start); window.addEventListener("popstate", start);
document.addEventListener("click", onClickCapture, true);
return () => { return () => {
window.history.pushState = origPush; window.history.pushState = origPush;
window.history.replaceState = origReplace; window.history.replaceState = origReplace;
window.removeEventListener("popstate", start); window.removeEventListener("popstate", start);
document.removeEventListener("click", onClickCapture, true);
clearTimeout(timers.current.slow); clearTimeout(timers.current.slow);
clearTimeout(timers.current.done); clearTimeout(timers.current.done);
clearTimeout(timers.current.guard);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 导航完成(pathname/search 变化)→ 冲刺到 100% → 淡出 // 导航完成(pathname/search 变化)→ 冲刺 100% → 淡出
useEffect(() => { useEffect(() => {
setVisible((visibleNow) => { setVisible((visibleNow) => {
if (!visibleNow) return visibleNow; if (!visibleNow) return visibleNow;