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

- Name
- Jack Qin
我们对 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函数,它await了apiClient.get(...),后者又await了fetch(...);- 整条内层链路完全成功:
fetch返回status=200,body 解析出{ providers: ["credentials", "microsoft"] },authClient.providers也确实返回了这个对象; - 但
queryFnbody 里那个await解析出来却是undefined。于是r.providers抛TypeError,catch吞掉,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/await(authClient、apiClient、fetch)全都正常,故障只钉在 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 层(authClient、apiClient、自定义 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 回退都必须:
- 至少把错误记一次(dev 用
console.warn,prod 用真实 logger),带上 query key 和原始错误; - 注释清楚为什么这个 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 出预期 payload | queryFn 返回映射后的数据 |
Client reject 一个 ApiError | queryFn 重新抛出,或返回文档化 fallback,并且调用了告警 logger |
signal 已 abort | queryFn 重抛 AbortError 或返回 fallback——绝不静默返回 undefined |
第三条专门防住引出本文的那个回归——它把"绝不静默返回 undefined"从口头约定变成了红/绿断言。
可迁移的那一层
抛开 Hermes 和 react-query 的具体细节,这个案例有两条可迁移认知。
一个"普遍正确"的语言特性,在某个特定运行时 × 框架边界上可能失效。 async/await 在规范层面无可挑剔,但它的正确性依赖底层调度。当你发现某个边界上"内层成功、外层却空",与其怀疑业务逻辑,不如先怀疑这个边界本身——把它换成更原始的 Promise 形态试一次,是廉价的二分。
静默的 fail-soft 会把"诊断信号"也一起吞掉。 容错和可观测性是两件事:你可以选择优雅降级,但降级路径必须留痕。一个连日志都没有的 catch,等于把"系统坏了"重新编码成了"功能本就如此"——这是排查成本最高的伪装。设计任何回退时都问一句:如果这条回退被错误地触发了,我有没有办法知道?