Published on

一个 await 解析成 undefined:运行时边界、静默回退与"形态约束"的机制账

Authors
  • avatar
    Name
    Jack Qin
    Twitter

我们对 async/await 有一个近乎信仰的假设:只要内层 Promise 成功 resolve 了某个值,外层 await 就一定拿到那个值。这个假设在规范层面是对的,在 V8/JSC 上也从未出过岔子,以至于我们从不把"await x 等于 x resolve 的值"当成一个有前提的结论。

但这个等式依赖一条隐含前提:底层的 microtask 调度、Promise 实现、以及框架在这个调用点施加的取消/生命周期机制,三者协调一致。当 queryFn 这个特定边界同时叠上 react-query v5 的 signal 取消、dev-mode observer 生命周期、以及 Hermes 在 RN 0.81 上的 async 实现时,这条前提会破——一个内层明明 resolve 了正确值的 await,在 queryFn body 里解析出来却是 undefined。本文想拆的不是"Hermes 有 bug"这个结论,而是这个故障的完整机制账:运行时边界、静默回退、以及由此逼出的两条"形态约束"为什么有迁移价值。


现象:内层全成功,边界上却是 undefined

栈是 Expo SDK 56 → RN 0.81 → Hermes → @tanstack/react-query@5.99.x。Web 端不受影响——它跑在 V8/JSC 上,queryFn 里写 async/await 完全合规。但移动端遇到一个极邪门的回归:"使用 Microsoft 登录"按钮无故消失

按钮的显隐由一个 providers 接口决定,queryFn 当时这么写:

// 错误写法(移动端)—— queryFn body 里用了 await
queryOptions({
  queryKey: ['auth', 'providers'],
  queryFn: async ({ signal }) => {
    try {
      const r = await authClient.providers({ signal }) // ← r 可能是 undefined
      return r.providers
    } catch {
      return [...FALLBACK_PROVIDERS]
    }
  },
})

跟踪下来的事实链非常违反直觉:

  • authClient.providers({ signal }) 是个 async 函数,它 awaitapiClient.get(...),后者又 awaitfetch(...)
  • 整条内层链路完全成功fetch 返回 status=200,body 解析出 { providers: ["credentials", "microsoft"] }authClient.providers 也确实返回了这个对象;
  • queryFn body 里那个 await 解析出来却是 undefined。于是 r.providersTypeErrorcatch 吞掉,query 落到 fallback——SSO 按钮就这么没了。

queryFn body 换成 .then 链——同一个 authClient.providers 调用、同一个 signal、其余一切不变——bug 消失。


机制:一个特定运行时边界上的协调失败

老实说,根因没有 100% 钉死。可观测的事实是:故障发生在 queryFn 这个特定函数边界上,且在以下三者同时在场时出现:

  • react-query v5 的 signal 驱动取消机制;
  • react-query 的 dev-mode observer 生命周期;
  • Hermes 在 RN 0.81 上对 async 函数 / microtask 调度的实现。

三者凑在一起,导致这个边界上的 await 解析成了 undefined,即便内层 Promise 明明已 resolve 正确值。关键证据是边界的特异性:内层那些 async/awaitauthClientapiClientfetch)全都正常,故障只钉在 react-query 直接调用的那一层 queryFn 上。这说明问题不在 Hermes 的 async 本身,而在 Hermes 的 async 与 react-query 在这个调用点施加的取消/观察机制的交汇处——又一个"三个机制单独都对、交汇处前提失效"的结构。

这也直接给出修复形态:把 queryFn body 写成 .then 链,避开在这个边界上生成 async 函数的 microtask 编排,问题就稳定消失。


形态约束一:queryFn 用 .then,多步控制流外移

// 单次 fetch + 失败软回退
queryFn: ({ signal }) =>
  authClient.providers({ signal })
    .then((r) => r.providers)
    .catch(() => [...FALLBACK_PROVIDERS]),

// 单次 fetch + 抛出错误
queryFn: ({ signal }) =>
  authClient.me({ signal }).then((dto) => normaliseUser(dto)),

// 链式 fetch
queryFn: ({ signal }) =>
  authClient.me({ signal })
    .then((user) => sitesClient.listForUser(user.id, { signal }))
    .then((sites) => sites.map(toViewModel)),

// 并行 fetch
queryFn: ({ signal }) =>
  Promise.all([
    sitesClient.list({ signal }),
    timeWindowClient.current({ signal }),
  ]).then(([sites, window]) => composeDashboard(sites, window)),

这条约束管传给 queryFn 的那个函数——它内部调用的 client 层(authClientapiClient、自定义 fetcher)随便用 async/await,那条路径是好的。这一点很关键:约束不是"全栈禁用 async",而是精确地钉在那个出问题的边界上。

如果你只是为了表达控制流而想用 async/await,把多步逻辑抽到 queryFn 外面的独立函数里——那个 helper 可以是 async

async function loadDashboard(signal: AbortSignal) {
  const user = await authClient.me({ signal })
  const sites = await sitesClient.listForUser(user.id, { signal })
  return { user, sites }
}

queryOptions({
  queryKey: DASHBOARD_QUERY_KEY,
  queryFn: ({ signal }) => loadDashboard(signal),
})

queryFn 本身保持一行,返回那个 async 函数的 Promise。跨过 queryFn 边界进到独立函数,似乎就绕开了问题——可读性和正确性两头都保住。不要为了"可读性"把 queryFn body 重构回 async/await:在移动端这不是风格选择,是正确性约束。


形态约束二:fail-soft 的 catch 绝不能完全静默

这个回归排查那么久,根因有一半在前面的运行时边界,另一半在回退是彻底静默的

} catch {
  return [...FALLBACK_PROVIDERS];   // 没日志,没 telemetry
}

它把一次网络/契约失败,无声地翻译成一个 UI feature flag 翻转("Microsoft 按钮就是不在")。静默回退本身不是坏设计——providers 接口不可达时让登录页以"仅凭证"渲染是合理的;坏的是它不留任何痕迹,于是一个错误的 base URL、一次 ATS 拒绝、一个 DNS 问题,全都伪装成同一个无害状态。所以今后所有 queryFn 的 fail-soft 回退都必须:

  1. 至少把错误记一次(dev 用 console.warn,prod 用真实 logger),带上 query key 和原始错误;
  2. 注释清楚为什么这个 query 可以回退("providers 是部署期开关,后端不可达就当没有额外 provider"),而不只是写回退成什么。
queryFn: ({ signal }) =>
  authClient.providers({ signal })
    .then((r) => r.providers)
    .catch((err) => {
      // providers 是一份部署期列表。providers 接口不可达时,我们让登录页
      // 以「仅凭证登录」渲染——但我们永远把失败暴露出来,这样一个错误的
      // base URL / ATS 拒绝 / DNS 问题就不会伪装成「Microsoft SSO 被禁用了」。
      console.warn("[useAuthProvidersQuery] providers fetch failed", err);
      return [...FALLBACK_PROVIDERS];
    }),

把两条约束钉成回归守卫

测试直接拿 mock 过的 client 去跑 queryFn

用例断言
Client resolve 出预期 payloadqueryFn 返回映射后的数据
Client reject 一个 ApiErrorqueryFn 重新抛出,或返回文档化 fallback,并且调用了告警 logger
signal 已 abortqueryFn 重抛 AbortError 或返回 fallback——绝不静默返回 undefined

第三条专门防住引出本文的那个回归——它把"绝不静默返回 undefined"从口头约定变成了红/绿断言。


可迁移的那一层

抛开 Hermes 和 react-query 的具体细节,这个案例有两条可迁移认知。

一个"普遍正确"的语言特性,在某个特定运行时 × 框架边界上可能失效。 async/await 在规范层面无可挑剔,但它的正确性依赖底层调度。当你发现某个边界上"内层成功、外层却空",与其怀疑业务逻辑,不如先怀疑这个边界本身——把它换成更原始的 Promise 形态试一次,是廉价的二分。

静默的 fail-soft 会把"诊断信号"也一起吞掉。 容错和可观测性是两件事:你可以选择优雅降级,但降级路径必须留痕。一个连日志都没有的 catch,等于把"系统坏了"重新编码成了"功能本就如此"——这是排查成本最高的伪装。设计任何回退时都问一句:如果这条回退被错误地触发了,我有没有办法知道?