Yumi0606的博客
文章
未分类

Bug 修复报告:locale 路径下浏览器回退后 URL 变化但页面不更新

2026年9月19日 7 分钟阅读 浏览 0 喜欢 0 评论 0

Bug 修复报告:locale 路径下浏览器回退后 URL 变化但页面不更新

Bug 现象

在中文 locale(/zh/ 前缀)下:

  1. 从 workspace 页面(/zh/workspace/chats/new#/settings/billing-and-usage)点击 Upgrade 跳转到 choose-package 页面(/zh/choose-package)
  2. 在 choose-package 页面点击叉叉(CloseIcon)或浏览器返回按钮
  3. 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)。

详细因果链:

  1. Next.js App Router 在每个 history 条目的 state 中存储了 __NA 标记和 __PRIVATE_NEXTJS_INTERNALS_TREE 路由树信息
  2. Next.js 的 onPopState 处理器(位于 next/dist/esm/client/components/app-router.js)在浏览器回退时检查 event.state:
    javascript
    const 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);
      });
    };
    
  3. window.location.hash = '...' 赋值操作会在浏览器中创建一个新的 history 条目,但新条目的 state 为 null,不包含 __NA 标记
  4. window.location.replace(url) 同样会完全替换当前 history 条目,state 被设为 null
  5. 当用户点击浏览器返回时,popstate 事件的 event.state 为 null,Next.js 的 onPopState 直接 return,不做路由更新
  6. 结果是 URL 变了但页面不更新

为什么英文路径不受影响:英文路径下不设置 hash 路由(设置弹窗的 hash 路由是在 workspace 内部触发的),所以 history state 不被破坏。而中文路径下因为测试场景涉及设置弹窗的 hash 路由操作,导致 state 丢失。


追查过程和方法

第一阶段:静态代码分析

  1. 检查了 src/i18n/routing.ts 中 localePrefix: 'as-needed' 配置
  2. 分析了 next-intl 的 createNavigation 源码,发现 useRouter().back() 直接透传原生 router.back()(即 window.history.back()),没有做 locale 特殊处理
  3. 分析了 next-intl middleware 源码,确认在 /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') 调用

第三阶段:关键证据

第一次插桩收集到的日志:

text
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 追踪)收集到的日志:

text
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 方法

旧代码:

typescript
const setNav = useCallback(
  (id: SettingSubPageNavId) => {
    const newUrl = `${window.location.pathname}${window.location.search}${getSettingSubPageLocationHash(id)}`;
    window.location.replace(newUrl);  // ← 破坏 history state!
    setActiveNavId(id);
  },
  [],
);

新代码:

typescript
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

旧代码:

typescript
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!
};

新代码:

typescript
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

旧代码:

typescript
const handleSubSettingPage = useCallback((to: string) => {
  window.location.hash = `#/${to}`;  // ← 创建新 history 条目,丢失 state!
  setSettingsOpen(true);
}, [setSettingsOpen]);

新代码:

typescript
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

旧代码:

typescript
const openSettingSubPage = useCallback((to: string) => {
  handleCloseAllMenus();
  window.location.hash = `#/${to}`;  // ← 丢失 state!
  setSettingsOpen(true);
}, [handleCloseAllMenus, setSettingsOpen]);

新代码:

typescript
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 菜单项

旧代码:

typescript
onClick={() => {
  window.location.hash = `#/${NAV_ID_TO_HASH.connectors}`;  // ← 丢失 state!
  closeConnectorMenu();
  setSettingsOpen(true);
}}

新代码:

typescript
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 导航

旧代码:

typescript
const handleOpenUsageBilling = useCallback(() => {
  window.location.hash = "#/settings/billing-and-usage/usage-billing";  // ← 丢失 state!
}, []);

const handleCloseUsageBilling = useCallback(() => {
  window.location.hash = "#/settings/billing-and-usage";  // ← 丢失 state!
}, []);

新代码:

typescript
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 更新

旧代码:

typescript
history.replaceState(
  null,  // ← 丢失 state!
  "",
  getLocalizedPath(locale, pathOfThread(createdThreadId)),
);

新代码:

typescript
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 = urlrouter.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) — 错误,会清除 state
  • history.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 的逻辑(如设置弹窗的导航),需要手动触发:

typescript
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。

喜欢 0
评论区在赶来的路上...