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

原文：https://blog.yumi0606.top/posts/bug-xiu-fu-bao-gaolocale--lu-jing-xia-liu/

作者：羽弥

发布时间：2026-09-19T14:28:19Z

内容更新时间：2026-09-19T14:28:19Z

# 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')` 调用

### 第三阶段：关键证据

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

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

**旧代码**：
```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 = 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)` — **错误**，会清除 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。

