Bug 修复报告:locale 路径下浏览器回退后 URL 变化但页面不更新
Bug 修复报告:locale 路径下浏览器回退后 URL 变化但页面不更新
Bug 现象
在中文 locale(/zh/ 前缀)下:
- 从 workspace 页面(
/zh/workspace/chats/new#/settings/billing-and-usage)点击 Upgrade 跳转到 choose-package 页面(/zh/choose-package) - 在 choose-package 页面点击叉叉(CloseIcon)或浏览器返回按钮
- URL 变成
http://localhost:5173/zh/workspace/chats/new#/settings/billing-and-usage,但页面没有任何变动,仍然停留在 choose-package 页面
而在英文 locale(无 /zh/ 前缀)下,同样的操作可以正常工作:URL 变化后页面正确更新到 workspace 页面。
根本原因
核心问题:代码中使用 window.location.hash = ... 和 window.location.replace(...) 操作 hash 路由时,会丢失 Next.js 维护在 history.state 中的内部标记(__NA 和 __PRIVATE_NEXTJS_INTERNALS_TREE)。
详细因果链:
- Next.js App Router 在每个 history 条目的
state中存储了__NA标记和__PRIVATE_NEXTJS_INTERNALS_TREE路由树信息 - Next.js 的
onPopState处理器(位于next/dist/esm/client/components/app-router.js)在浏览器回退时检查event.state:javascriptconst onPopState = (event) => { if (!event.state) { // ← 直接返回,不做任何路由更新! return; } if (!event.state.__NA) { window.location.reload(); return; } startTransition(() => { dispatchTraverseAction(window.location.href, event.state.__PRIVATE_NEXTJS_INTERNALS_TREE); }); }; window.location.hash = '...'赋值操作会在浏览器中创建一个新的 history 条目,但新条目的 state 为null,不包含__NA标记window.location.replace(url)同样会完全替换当前 history 条目,state 被设为null- 当用户点击浏览器返回时,
popstate事件的event.state为null,Next.js 的onPopState直接return,不做路由更新 - 结果是 URL 变了但页面不更新
为什么英文路径不受影响:英文路径下不设置 hash 路由(设置弹窗的 hash 路由是在 workspace 内部触发的),所以 history state 不被破坏。而中文路径下因为测试场景涉及设置弹窗的 hash 路由操作,导致 state 丢失。
追查过程和方法
第一阶段:静态代码分析
- 检查了
src/i18n/routing.ts中localePrefix: 'as-needed'配置 - 分析了
next-intl的createNavigation源码,发现useRouter().back()直接透传原生router.back()(即window.history.back()),没有做 locale 特殊处理 - 分析了
next-intlmiddleware 源码,确认在/zh/前缀路径下 middleware 行为正确(只做 rewrite 不做 redirect)
第二阶段:运行时调试(插桩验证)
启动 Debug Server(端口 7777),在以下关键位置添加插桩日志:
- choose-package/page.tsx:
router.back()调用前后、popstate事件 - workspace-nav-menu.tsx:
SettingDialogProvider挂载、hash 检查 - setting-sub-page/index.tsx:hash 变化、
setNav调用 - plan-summary-card.tsx:
router.push('/choose-package')调用
第三阶段:关键证据
第一次插桩收集到的日志:
router.back() called:
currentUrl = http://localhost:5173/zh/choose-package
historyLength = 11
popstate event:
currentUrl = http://localhost:5173/zh/workspace/chats/new#/settings/billing-and-usage
hasState = false ← 关键!event.state 为 null
hasNA = false ← 关键!缺少 __NA 标记
stateKeys = null
没有 SettingDialogProvider mounted 日志——说明 workspace layout 没有被重新挂载。
第二次插桩(增加 history.state 追踪)收集到的日志:
handleSubSettingPage(打开设置弹窗):
currentStateHasNA = true ← 此时 state 还正常
setNav(切换设置子页):
currentStateHasNA = false ← window.location.hash 赋值后 state 已丢失!
currentStateKeys = null
router.push('/choose-package')(跳转):
historyState.hasState = false ← state 已丢失
historyState.hasNA = false
这证实了 window.location.hash = ... 是破坏 history state 的罪魁祸首。
第四阶段:源码验证
检查 Next.js 源码确认了 onPopState 的行为:
event.state为null→ 直接return,不做路由更新event.state存在但缺少__NA→window.location.reload()event.state.__NA存在 → 正常执行dispatchTraverseAction
涉及的新旧代码段和区别
1. src/components/setting-sub-page/index.tsx — setNav 方法
旧代码:
const setNav = useCallback(
(id: SettingSubPageNavId) => {
const newUrl = `${window.location.pathname}${window.location.search}${getSettingSubPageLocationHash(id)}`;
window.location.replace(newUrl); // ← 破坏 history state!
setActiveNavId(id);
},
[],
);
新代码:
const setNav = useCallback(
(id: SettingSubPageNavId) => {
const hash = getSettingSubPageLocationHash(id);
// 使用 history.replaceState 而非 window.location.replace,以保留 Next.js 内部的
// __NA 标记和 __PRIVATE_NEXTJS_INTERNALS_TREE,否则浏览器回退时 popstate 事件的
// state 为 null,Next.js 的 onPopState 会直接返回不做路由更新。
window.history.replaceState(window.history.state, '', hash);
setActiveNavId(id);
},
[],
);
区别:window.location.replace() 完全替换 history 条目并丢失 state;window.history.replaceState(window.history.state, '', ...) 只替换 URL,保留原有 state。Next.js 已拦截了 history.replaceState,会自动复制内部状态。
2. src/components/setting-sub-page/index.tsx — handleCloseIcon
旧代码:
const handleCloseIcon = () => {
console.log('handleClose Setting ==> ');
window.history.replaceState(null, '', window.location.pathname + window.location.search); // ← null 破坏 state!
setOpen(false);
history.replaceState(null, "", window.location.pathname + window.location.search); // ← 重复调用且同样传入 null!
};
新代码:
const handleCloseIcon = () => {
console.log('handleClose Setting ==> ');
// 彻底抹除 Hash,但保留 Next.js 内部的 __NA 标记和 __PRIVATE_NEXTJS_INTERNALS_TREE
window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search);
setOpen(false);
};
区别:replaceState(null, ...) 会将 state 设为 null;改为传入 window.history.state 保留原有 state。同时移除了重复的 history.replaceState 调用。
3. src/components/workspace/workspace-nav-menu.tsx — handleSubSettingPage
旧代码:
const handleSubSettingPage = useCallback((to: string) => {
window.location.hash = `#/${to}`; // ← 创建新 history 条目,丢失 state!
setSettingsOpen(true);
}, [setSettingsOpen]);
新代码:
const handleSubSettingPage = useCallback((to: string) => {
// 使用 history.pushState 而非 window.location.hash 赋值,以保留 Next.js 内部状态
const newUrl = `${window.location.pathname}${window.location.search}#/${to}`;
window.history.pushState(window.history.state, '', newUrl);
// 手动触发 hashchange 事件,因为 history.pushState 不会触发
window.dispatchEvent(new HashChangeEvent('hashchange'));
setSettingsOpen(true);
}, [setSettingsOpen]);
区别:window.location.hash = ... 赋值会创建新 history 条目且 state 为 null;history.pushState(window.history.state, '', ...) 创建新条目但复制原有 state。由于 pushState 不触发 hashchange 事件,需要手动 dispatchEvent。
4. src/components/workspace/user-chat-profile.tsx — openSettingSubPage
旧代码:
const openSettingSubPage = useCallback((to: string) => {
handleCloseAllMenus();
window.location.hash = `#/${to}`; // ← 丢失 state!
setSettingsOpen(true);
}, [handleCloseAllMenus, setSettingsOpen]);
新代码:
const openSettingSubPage = useCallback((to: string) => {
handleCloseAllMenus();
const newUrl = `${window.location.pathname}${window.location.search}#/${to}`;
window.history.pushState(window.history.state, '', newUrl);
window.dispatchEvent(new HashChangeEvent('hashchange'));
setSettingsOpen(true);
}, [handleCloseAllMenus, setSettingsOpen]);
5. src/components/workspace/input-box.tsx — connectors 菜单项
旧代码:
onClick={() => {
window.location.hash = `#/${NAV_ID_TO_HASH.connectors}`; // ← 丢失 state!
closeConnectorMenu();
setSettingsOpen(true);
}}
新代码:
onClick={() => {
const newUrl = `${window.location.pathname}${window.location.search}#/${NAV_ID_TO_HASH.connectors}`;
window.history.pushState(window.history.state, '', newUrl);
window.dispatchEvent(new HashChangeEvent('hashchange'));
closeConnectorMenu();
setSettingsOpen(true);
}}
6. src/components/setting-sub-page/billing/index.tsx — usage-billing 导航
旧代码:
const handleOpenUsageBilling = useCallback(() => {
window.location.hash = "#/settings/billing-and-usage/usage-billing"; // ← 丢失 state!
}, []);
const handleCloseUsageBilling = useCallback(() => {
window.location.hash = "#/settings/billing-and-usage"; // ← 丢失 state!
}, []);
新代码:
const handleOpenUsageBilling = useCallback(() => {
const newUrl = `${window.location.pathname}${window.location.search}#/settings/billing-and-usage/usage-billing`;
window.history.pushState(window.history.state, '', newUrl);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, []);
const handleCloseUsageBilling = useCallback(() => {
const newUrl = `${window.location.pathname}${window.location.search}#/settings/billing-and-usage`;
window.history.replaceState(window.history.state, '', newUrl);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, []);
7. src/app/[locale]/workspace/chats/[thread_id]/page.tsx — thread 创建后 URL 更新
旧代码:
history.replaceState(
null, // ← 丢失 state!
"",
getLocalizedPath(locale, pathOfThread(createdThreadId)),
);
新代码:
history.replaceState(
window.history.state, // ← 保留 Next.js 内部状态
"",
getLocalizedPath(locale, pathOfThread(createdThreadId)),
);
8. src/app/[locale]/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx — 同上
同第 7 点,null 改为 window.history.state。
如何杜绝该 Bug 发生
规则 1:在 Next.js App Router 中永远不要使用 window.location API 修改 URL
| 禁止使用 | 替代方案 |
|---|---|
window.location.hash = '...' | window.history.pushState(window.history.state, '', newUrl) + dispatchEvent(new HashChangeEvent('hashchange')) |
window.location.replace(url) | window.history.replaceState(window.history.state, '', url) |
window.location.href = url | router.push(url) 或 window.history.pushState(window.history.state, '', url) |
history.replaceState(null, '', url) | history.replaceState(window.history.state, '', url) |
原因:window.location 系列 API 不受 Next.js 拦截,会直接操作浏览器 history,导致 history.state 中的 __NA 标记和 __PRIVATE_NEXTJS_INTERNALS_TREE 丢失。
规则 2:使用 history.replaceState / history.pushState 时,第一个参数永远传入 window.history.state
history.replaceState(null, '', url)— 错误,会清除 statehistory.replaceState(window.history.state, '', url)— 正确,保留 state
Next.js 已拦截了 history.replaceState 和 history.pushState,会自动调用 copyNextJsInternalHistoryState(data) 复制内部状态,但前提是传入的 data 不是 null。
规则 3:使用 history.pushState 后需要手动触发 hashchange 事件
history.pushState 和 history.replaceState 不会触发 hashchange 事件,如果代码中有监听 hashchange 的逻辑(如设置弹窗的导航),需要手动触发:
window.history.pushState(window.history.state, '', newUrl);
window.dispatchEvent(new HashChangeEvent('hashchange'));
规则 4:ESLint 规则建议
可以考虑添加自定义 ESLint 规则,禁止以下模式:
window.location.hash =window.location.replace(window.location.href =window.location.assign(history.replaceState(null,history.pushState(null,
这些模式在 Next.js App Router 项目中都应被视为潜在 Bug。