<!-- Source: https://deepseek-browser-use.io.nexus/36 -->

# 页面正在导航与主 Frame 缺失：PAGE_NAVIGATING

只读命令突然报 `Cannot invoke "com.microsoft.playwright.Frame.childFrames()" because "frame" is null`，或者在构建快照时抛 `Index 0 out of bounds for length 0` —— 这两种异常看起来毫不相干，实际是同一件事：**这一刻页面的主 frame 是 null**。本章说明它从哪来、服务端怎么分类，以及 frame 枚举失效时怎么继续把任务做完。

## 1. 症状与成因

| 症状 | 出现位置 |
| --- | --- |
| `Cannot invoke "...Frame.childFrames()" because "frame" is null` | `get_browser_state`、`list_frames`、`get_modals`，以及带 `frame` 参数的选择器类命令 |
| `构建页面结构失败：Index 0 out of bounds for length 0` | `get_browser_state` 构建快照时 |

常见成因：

- 页面正在导航或**整页重建**（用户手动刷新、SPA 整页重建、micro-app 重新挂载）；
- 页面**停在一个重定向循环上**：地址栏在登录域与目标站之间来回跳，主 frame 一直没有稳定下来（第 4 节是真实案例）；
- 某些新版控制台的「lite」页面，页面对象一直不给出主 frame。

**它不该被当成「动作可能已生效」**。以前这类异常会冒到兜底分支、被归成 `ACTION_UNCERTAIN`（「无法判断是否已生效，别重试」）—— 而这明明是**等一会儿就好**的瞬时状态，方向正好相反。

## 2. 服务端怎么处理

链路上一共四处改动，合起来把这条路走通：

1. `DomService.frameTree`：主 frame 为 null 时**不再让 NPE 冒出去**，而是抛一个带 `page_navigating` 哨兵的异常。返回空列表会被误读成「空白页」，哨兵才能表明「这一刻只是拿不到 DOM」。
2. `ActionError.isPageNavigating`：认得这个哨兵（也认得驱动给的 `because "frame" is null`、`execution context was destroyed` 等措辞），归成 **`PAGE_NAVIGATING`**，并按可重试处理。
3. `PlaywrightService.getBrowserStateAttempt`：快照构建的 catch 从 `PlaywrightException` 放宽到 `RuntimeException`（NPE 与越界都包进来），是导航类失败就打上 `PAGE_NAVIGATING` 前缀。
4. `ActionService.dispatchWithSpuriousRetry`：重发不再只认伪故障，**页面正在导航**时也对可重发名单里的命令（只读 / 幂等导航 / 覆盖式落盘 / 等待）自动重试，成功后回执里给 `data.pageNavigatingRetry`，与伪故障的 `data.spuriousRetry` 分开记。

只读命令仍失败时，兜底分支给出的是：

```json
{
  "errorCode": "PAGE_NAVIGATING",
  "retryable": true,
  "pageNavigating": true,
  "retryAfterMs": 1000,
  "note": "页面正在导航/重建，这一刻拿不到 DOM；这是只读命令，重发无害，稍后再发即可。"
}
```

**动作类命令不会因此被自动重发**：回执 `retryable:false`、提示先读页面状态。这一点与 [防重复提交](<https://deepseek-browser-use.io.nexus/08>) 的原则一致 —— 页面在导航不代表动作没生效。

## 3. 调用方怎么用

- **只读命令**：看到 `PAGE_NAVIGATING` 且 `retryable:true`，按 `retryAfterMs`（约 1 秒）重发即可；多数情况下服务端已经替你重试过（看 `pageNavigatingRetry.attempts`）。
- **动作类命令**：不要自动补点。先用 `get_page_snapshot` / `get_browser_state` 确认上一次到底生效没有，再决定。
- **判断页面稳不稳定**：连读两次 `get_page_snapshot` 看 URL 是否还在变；地址一直在跳就属于第 4 节那种情况，别再等它「自己好」。

## 4. 实战：控制台一直重定向，怎么都到不了

实测（2026-10）要打开阿里云账单控制台，按直觉访问 `billing.console.aliyun.com`：地址栏立刻变成
`account.aliyun.com/login/login.htm?oauth_callback=…`，然后在 `account.aliyun.com` 与
`account.alibabacloud.com` 之间来回弹，**永远进不去**；这一页上 `get_browser_state` / `list_frames`
一直报主 frame 为 null。真相是**账单控制台的真实主机名是 `billing-cost.console.aliyun.com`**（只差一个 `-cost`），旧主机名只是把你弹回登录页。

排查顺序：

1. **看到「一直在登录页打转」先怀疑主机名**，不要在这一页上找元素、换选择器、反复填登录表单；
2. 回一个**已经登录**的控制台，用 `execute_js` 把导航链接的 `href` 全读出来，从真实链接里照抄主机名与路径：
   `[...document.querySelectorAll('a')].map(a => ({ t: a.innerText.trim(), h: a.href }))`；
3. 用 `get_page_snapshot` 确认 URL 稳定下来，再继续后续步骤。

## 5. 实战：frame 枚举失效时，怎么操作跨域 iframe

主 frame 为 null 时，受影响的不只是快照：`frameOf` 内部同样走 `frameTree`，所以**带 `frame` 参数的选择器命令也会失败**，跨域 iframe（例如短信验证码弹层）就「够不着」了。此时可以按坐标操作：

1. `execute_js` 量出 iframe 的位置：`document.querySelector('iframe[src*="…"]').getBoundingClientRect()`；
2. `screenshot` 传 `selector=iframe[src*="…"]`，**只截那一个 iframe**（默认落盘，回 `data.path`）；
3. 用 `ocr_image` 读出上面的文字；验证码这类「文本表达不了」的内容可以直接看图确认布局；
4. 用 `mouse_click` 按坐标点击；**鼠标点不动的按钮改用键盘**：先点一下输入框让 iframe 拿到焦点，再 `send_keys` 的 `Tab` + `Enter`；
5. 每一步之后回读页面（`execute_js` 取 `innerText`）确认状态，而不是假设点中了。

这条路的代价是「看不见结构化元素」，所以顺序不能省：**先量位置 → 截图确认 → 再点**；点完必须回读。

## 6. 相关代码与测试

| 位置 | 作用 |
| --- | --- |
| `DomService.frameTree` | 主 frame 为 null → 抛带 `page_navigating` 哨兵的异常 |
| `ActionError.isPageNavigating` / `code` | 归类成 `PAGE_NAVIGATING`，标记可重试 |
| `PlaywrightService.getBrowserStateAttempt` | 快照构建兜住 `RuntimeException`，打上分类前缀 |
| `ActionService.dispatchWithSpuriousRetry` / `retryReason` | 只读命令在导航/伪故障两类起因下自动重试，分别记 `pageNavigatingRetry` / `spuriousRetry` |

钉住这些行为的测试：`ActionErrorTest.pageNavigatingIsItsOwnRetryableCode`、
`SpuriousDispatchRetryTest.readOnlyCommandRetriesWhilePageIsNavigating`、
`SpuriousDispatchRetryTest.actionCommandIsNotRetriedWhilePageIsNavigating`。

相关章节：[统一命令接口与错误码](<https://deepseek-browser-use.io.nexus/04>)、[执行链与异常处理](<https://deepseek-browser-use.io.nexus/14>)、
[DOM 与跨 Frame 索引](<https://deepseek-browser-use.io.nexus/16>)、[页面状态与元素读取](<https://deepseek-browser-use.io.nexus/19>)、[等待与重试边界](<https://deepseek-browser-use.io.nexus/21>)。
