<?xml version="1.0" encoding="UTF-8"?><rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Yumi0606的博客</title>
    <link>https://blog.yumi0606.top/</link>
    <description><![CDATA[新晋全栈工程师的个人博客]]></description>
    <language>zh-CN</language>
    <managingEditor>1015622964@qq.com (羽弥)</managingEditor>
    <pubDate>Sat, 19 Sep 2026 14:28:19 +0000</pubDate>
    <lastBuildDate>Sat, 19 Sep 2026 14:28:19 +0000</lastBuildDate>
    <generator>grtblog v2.2.0</generator>
    <image>
      <url>https://blog.yumi0606.top/uploads/pictures/2026-09-17-14:41:40-69.png</url>
      <title>Yumi0606的博客</title>
      <link>https://blog.yumi0606.top/</link>
    </image>
    <atom:link href="https://blog.yumi0606.top/feed" rel="self" type="application/rss+xml"/><item>
      <title>Bug 修复报告：locale 路径下浏览器回退后 URL 变化但页面不更新</title>
      <link>https://blog.yumi0606.top/posts/bug-xiu-fu-bao-gaolocale--lu-jing-xia-liu</link>
      <description><![CDATA[<blockquote><p>该内容由 RSS 渲染生成，最佳阅读体验请前往：<a href="https://blog.yumi0606.top/posts/bug-xiu-fu-bao-gaolocale--lu-jing-xia-liu">https://blog.yumi0606.top/posts/bug-xiu-fu-bao-gaolocale--lu-jing-xia-liu</a></p></blockquote><h1>Bug 修复报告：locale 路径下浏览器回退后 URL 变化但页面不更新</h1>
<h2>Bug 现象</h2>
<p>在中文 locale（<code>/zh/</code> 前缀）下：</p>
<ol>
<li>从 workspace 页面（<code>/zh/workspace/chats/new#/settings/billing-and-usage</code>）点击 Upgrade 跳转到 choose-package 页面（<code>/zh/choose-package</code>）</li>
<li>在 choose-package 页面点击叉叉（CloseIcon）或浏览器返回按钮</li>
<li>URL 变成 <code>http://localhost:5173/zh/workspace/chats/new#/settings/billing-and-usage</code>，但<strong>页面没有任何变动</strong>，仍然停留在 choose-package 页面</li>
</ol>
<p>而在英文 locale（无 <code>/zh/</code> 前缀）下，同样的操作可以正常工作：URL 变化后页面正确更新到 workspace 页面。</p>
<hr>
<h2>根本原因</h2>
<p><strong>核心问题</strong>：代码中使用 <code>window.location.hash = ...</code> 和 <code>window.location.replace(...)</code> 操作 hash 路由时，会<strong>丢失 Next.js 维护在 <code>history.state</code> 中的内部标记</strong>（<code>__NA</code> 和 <code>__PRIVATE_NEXTJS_INTERNALS_TREE</code>）。</p>
<p><strong>详细因果链</strong>：</p>
<ol>
<li>Next.js App Router 在每个 history 条目的 <code>state</code> 中存储了 <code>__NA</code> 标记和 <code>__PRIVATE_NEXTJS_INTERNALS_TREE</code> 路由树信息</li>
<li>Next.js 的 <code>onPopState</code> 处理器（位于 <code>next/dist/esm/client/components/app-router.js</code>）在浏览器回退时检查 <code>event.state</code>：
<pre><code class="language-javascript">const onPopState = (event) =&gt; {
  if (!event.state) {
    // ← 直接返回，不做任何路由更新！
    return;
  }
  if (!event.state.__NA) {
    window.location.reload();
    return;
  }
  startTransition(() =&gt; {
    dispatchTraverseAction(window.location.href, event.state.__PRIVATE_NEXTJS_INTERNALS_TREE);
  });
};
</code></pre>
</li>
<li><code>window.location.hash = '...'</code> 赋值操作会在浏览器中创建一个新的 history 条目，但<strong>新条目的 state 为 <code>null</code></strong>，不包含 <code>__NA</code> 标记</li>
<li><code>window.location.replace(url)</code> 同样会<strong>完全替换当前 history 条目</strong>，state 被设为 <code>null</code></li>
<li>当用户点击浏览器返回时，<code>popstate</code> 事件的 <code>event.state</code> 为 <code>null</code>，Next.js 的 <code>onPopState</code> 直接 <code>return</code>，不做路由更新</li>
<li>结果是 URL 变了但页面不更新</li>
</ol>
<p><strong>为什么英文路径不受影响</strong>：英文路径下不设置 hash 路由（设置弹窗的 hash 路由是在 workspace 内部触发的），所以 history state 不被破坏。而中文路径下因为测试场景涉及设置弹窗的 hash 路由操作，导致 state 丢失。</p>
<hr>
<h2>追查过程和方法</h2>
<h3>第一阶段：静态代码分析</h3>
<ol>
<li>检查了 <code>src/i18n/routing.ts</code> 中 <code>localePrefix: 'as-needed'</code> 配置</li>
<li>分析了 <code>next-intl</code> 的 <code>createNavigation</code> 源码，发现 <code>useRouter().back()</code> 直接透传原生 <code>router.back()</code>（即 <code>window.history.back()</code>），没有做 locale 特殊处理</li>
<li>分析了 <code>next-intl</code> middleware 源码，确认在 <code>/zh/</code> 前缀路径下 middleware 行为正确（只做 rewrite 不做 redirect）</li>
</ol>
<h3>第二阶段：运行时调试（插桩验证）</h3>
<p>启动 Debug Server（端口 7777），在以下关键位置添加插桩日志：</p>
<ul>
<li><strong>choose-package/page.tsx</strong>：<code>router.back()</code> 调用前后、<code>popstate</code> 事件</li>
<li><strong>workspace-nav-menu.tsx</strong>：<code>SettingDialogProvider</code> 挂载、hash 检查</li>
<li><strong>setting-sub-page/index.tsx</strong>：hash 变化、<code>setNav</code> 调用</li>
<li><strong>plan-summary-card.tsx</strong>：<code>router.push('/choose-package')</code> 调用</li>
</ul>
<h3>第三阶段：关键证据</h3>
<p>第一次插桩收集到的日志：</p>
<pre><code>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
</code></pre>
<p><strong>没有</strong> <code>SettingDialogProvider mounted</code> 日志——说明 workspace layout 没有被重新挂载。</p>
<p>第二次插桩（增加 <code>history.state</code> 追踪）收集到的日志：</p>
<pre><code>handleSubSettingPage（打开设置弹窗）:
  currentStateHasNA = true    ← 此时 state 还正常

setNav（切换设置子页）:
  currentStateHasNA = false   ← window.location.hash 赋值后 state 已丢失！
  currentStateKeys = null

router.push('/choose-package')（跳转）:
  historyState.hasState = false   ← state 已丢失
  historyState.hasNA = false
</code></pre>
<p>这证实了 <code>window.location.hash = ...</code> 是破坏 history state 的罪魁祸首。</p>
<h3>第四阶段：源码验证</h3>
<p>检查 Next.js 源码确认了 <code>onPopState</code> 的行为：</p>
<ul>
<li><code>event.state</code> 为 <code>null</code> → 直接 <code>return</code>，不做路由更新</li>
<li><code>event.state</code> 存在但缺少 <code>__NA</code> → <code>window.location.reload()</code></li>
<li><code>event.state.__NA</code> 存在 → 正常执行 <code>dispatchTraverseAction</code></li>
</ul>
<hr>
<h2>涉及的新旧代码段和区别</h2>
<h3>1. <code>src/components/setting-sub-page/index.tsx</code> — <code>setNav</code> 方法</h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">const setNav = useCallback(
  (id: SettingSubPageNavId) =&gt; {
    const newUrl = `${window.location.pathname}${window.location.search}${getSettingSubPageLocationHash(id)}`;
    window.location.replace(newUrl);  // ← 破坏 history state！
    setActiveNavId(id);
  },
  [],
);
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">const setNav = useCallback(
  (id: SettingSubPageNavId) =&gt; {
    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);
  },
  [],
);
</code></pre>
<p><strong>区别</strong>：<code>window.location.replace()</code> 完全替换 history 条目并丢失 state；<code>window.history.replaceState(window.history.state, '', ...)</code> 只替换 URL，保留原有 state。Next.js 已拦截了 <code>history.replaceState</code>，会自动复制内部状态。</p>
<h3>2. <code>src/components/setting-sub-page/index.tsx</code> — <code>handleCloseIcon</code></h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">const handleCloseIcon = () =&gt; {
  console.log('handleClose Setting ==&gt; ');
  window.history.replaceState(null, '', window.location.pathname + window.location.search);  // ← null 破坏 state！
  setOpen(false);
  history.replaceState(null, &quot;&quot;, window.location.pathname + window.location.search);  // ← 重复调用且同样传入 null！
};
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">const handleCloseIcon = () =&gt; {
  console.log('handleClose Setting ==&gt; ');
  // 彻底抹除 Hash，但保留 Next.js 内部的 __NA 标记和 __PRIVATE_NEXTJS_INTERNALS_TREE
  window.history.replaceState(window.history.state, '', window.location.pathname + window.location.search);
  setOpen(false);
};
</code></pre>
<p><strong>区别</strong>：<code>replaceState(null, ...)</code> 会将 state 设为 null；改为传入 <code>window.history.state</code> 保留原有 state。同时移除了重复的 <code>history.replaceState</code> 调用。</p>
<h3>3. <code>src/components/workspace/workspace-nav-menu.tsx</code> — <code>handleSubSettingPage</code></h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">const handleSubSettingPage = useCallback((to: string) =&gt; {
  window.location.hash = `#/${to}`;  // ← 创建新 history 条目，丢失 state！
  setSettingsOpen(true);
}, [setSettingsOpen]);
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">const handleSubSettingPage = useCallback((to: string) =&gt; {
  // 使用 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]);
</code></pre>
<p><strong>区别</strong>：<code>window.location.hash = ...</code> 赋值会创建新 history 条目且 state 为 null；<code>history.pushState(window.history.state, '', ...)</code> 创建新条目但复制原有 state。由于 <code>pushState</code> 不触发 <code>hashchange</code> 事件，需要手动 <code>dispatchEvent</code>。</p>
<h3>4. <code>src/components/workspace/user-chat-profile.tsx</code> — <code>openSettingSubPage</code></h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">const openSettingSubPage = useCallback((to: string) =&gt; {
  handleCloseAllMenus();
  window.location.hash = `#/${to}`;  // ← 丢失 state！
  setSettingsOpen(true);
}, [handleCloseAllMenus, setSettingsOpen]);
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">const openSettingSubPage = useCallback((to: string) =&gt; {
  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]);
</code></pre>
<h3>5. <code>src/components/workspace/input-box.tsx</code> — connectors 菜单项</h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">onClick={() =&gt; {
  window.location.hash = `#/${NAV_ID_TO_HASH.connectors}`;  // ← 丢失 state！
  closeConnectorMenu();
  setSettingsOpen(true);
}}
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">onClick={() =&gt; {
  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);
}}
</code></pre>
<h3>6. <code>src/components/setting-sub-page/billing/index.tsx</code> — usage-billing 导航</h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">const handleOpenUsageBilling = useCallback(() =&gt; {
  window.location.hash = &quot;#/settings/billing-and-usage/usage-billing&quot;;  // ← 丢失 state！
}, []);

const handleCloseUsageBilling = useCallback(() =&gt; {
  window.location.hash = &quot;#/settings/billing-and-usage&quot;;  // ← 丢失 state！
}, []);
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">const handleOpenUsageBilling = useCallback(() =&gt; {
  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(() =&gt; {
  const newUrl = `${window.location.pathname}${window.location.search}#/settings/billing-and-usage`;
  window.history.replaceState(window.history.state, '', newUrl);
  window.dispatchEvent(new HashChangeEvent('hashchange'));
}, []);
</code></pre>
<h3>7. <code>src/app/[locale]/workspace/chats/[thread_id]/page.tsx</code> — thread 创建后 URL 更新</h3>
<p><strong>旧代码</strong>：</p>
<pre><code class="language-typescript">history.replaceState(
  null,  // ← 丢失 state！
  &quot;&quot;,
  getLocalizedPath(locale, pathOfThread(createdThreadId)),
);
</code></pre>
<p><strong>新代码</strong>：</p>
<pre><code class="language-typescript">history.replaceState(
  window.history.state,  // ← 保留 Next.js 内部状态
  &quot;&quot;,
  getLocalizedPath(locale, pathOfThread(createdThreadId)),
);
</code></pre>
<h3>8. <code>src/app/[locale]/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx</code> — 同上</h3>
<p>同第 7 点，<code>null</code> 改为 <code>window.history.state</code>。</p>
<hr>
<h2>如何杜绝该 Bug 发生</h2>
<h3>规则 1：在 Next.js App Router 中永远不要使用 <code>window.location</code> API 修改 URL</h3>
<table>
<thead>
<tr>
<th>禁止使用</th>
<th>替代方案</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>window.location.hash = '...'</code></td>
<td><code>window.history.pushState(window.history.state, '', newUrl)</code> + <code>dispatchEvent(new HashChangeEvent('hashchange'))</code></td>
</tr>
<tr>
<td><code>window.location.replace(url)</code></td>
<td><code>window.history.replaceState(window.history.state, '', url)</code></td>
</tr>
<tr>
<td><code>window.location.href = url</code></td>
<td><code>router.push(url)</code> 或 <code>window.history.pushState(window.history.state, '', url)</code></td>
</tr>
<tr>
<td><code>history.replaceState(null, '', url)</code></td>
<td><code>history.replaceState(window.history.state, '', url)</code></td>
</tr>
</tbody>
</table>
<p><strong>原因</strong>：<code>window.location</code> 系列 API 不受 Next.js 拦截，会直接操作浏览器 history，导致 <code>history.state</code> 中的 <code>__NA</code> 标记和 <code>__PRIVATE_NEXTJS_INTERNALS_TREE</code> 丢失。</p>
<h3>规则 2：使用 <code>history.replaceState</code> / <code>history.pushState</code> 时，第一个参数永远传入 <code>window.history.state</code></h3>
<ul>
<li><code>history.replaceState(null, '', url)</code> — <strong>错误</strong>，会清除 state</li>
<li><code>history.replaceState(window.history.state, '', url)</code> — <strong>正确</strong>，保留 state</li>
</ul>
<p>Next.js 已拦截了 <code>history.replaceState</code> 和 <code>history.pushState</code>，会自动调用 <code>copyNextJsInternalHistoryState(data)</code> 复制内部状态，但前提是传入的 <code>data</code> 不是 <code>null</code>。</p>
<h3>规则 3：使用 <code>history.pushState</code> 后需要手动触发 <code>hashchange</code> 事件</h3>
<p><code>history.pushState</code> 和 <code>history.replaceState</code> 不会触发 <code>hashchange</code> 事件，如果代码中有监听 <code>hashchange</code> 的逻辑（如设置弹窗的导航），需要手动触发：</p>
<pre><code class="language-typescript">window.history.pushState(window.history.state, '', newUrl);
window.dispatchEvent(new HashChangeEvent('hashchange'));
</code></pre>
<h3>规则 4：ESLint 规则建议</h3>
<p>可以考虑添加自定义 ESLint 规则，禁止以下模式：</p>
<ul>
<li><code>window.location.hash =</code></li>
<li><code>window.location.replace(</code></li>
<li><code>window.location.href =</code></li>
<li><code>window.location.assign(</code></li>
<li><code>history.replaceState(null,</code></li>
<li><code>history.pushState(null,</code></li>
</ul>
<p>这些模式在 Next.js App Router 项目中都应被视为潜在 Bug。</p>]]></description>
      <author>羽弥</author>
      <guid>article-1</guid>
      <pubDate>Sat, 19 Sep 2026 14:28:19 +0000</pubDate>
    </item>
  </channel>
</rss>