用途
async-safety 是一个针对 React Native / Expo 应用的异步安全技能,目标是让异步失败不再静默:每个 Promise 都有能看到其拒绝的归属者,每个 effect 都会取消自己启动的工作,重叠调用遵循最新优先,取消状态从 signal 读取,每个订阅和定时器由创建者释放,每个可能挂起的等待都有截止时间。它通过零依赖检查器发现 ESLint 和 tsc 看不到的问题,并用竞态测试验证其余部分,覆盖 iOS 与 Android 代码。
主要能力
该技能定义了一组必须成立的规则:
- 不把 Promise 放入忽略它的槽位:返回 Promise 的函数(async 函数、Promise<...> 类型、Async 调用、Linking.openURL)不能按引用传给 on 属性、on* 模型字段或监听器,必须包装,例如 onPress={() => { retry().catch(reportError); }}。原因是 React Native 的 Strict API 类型把 Pressable onPress、TextInput onSubmitEditing 和 Keyboard 监听器标注为返回 unknown,RefreshControl onRefresh 接受一个从不 await 的 Promise,因此没有 linter 能发现 onPress={retry},拒绝会在 release 中消失。
- 回调类型返回 void:每个 on* 属性、on* 模型字段和 handle* 成员都类型化为 (...) => void,绝不返回 Promise<...> 或 unknown;模型拥有 Promise 并暴露 void 意图及其状态(如 isSaving)。
- effect 拥有它启动的异步工作:每次运行一个 AbortController(或不接受 signal 的工作用 isActive 标志),由 cleanup 中止;await 之后的每次写入先检查 signal.aborted;异步到达的订阅(如 watchPositionAsync)在 cleanup 已运行时于到达时移除;state 记住它回答的是哪个请求。
- 同一读取的重叠调用遵循最新优先;写入绝不运行两次:搜索、过滤、刷新本地或 SDK 数据,以及 OS 值的首次读取,每个动作使用一个 createLatestRequest(),使更新的调用(或变更事件)中止较旧的调用;写入绝不为更新的调用而中止,并在进行中禁用。
- 取消从 signal 读取,且绝不视为失败:拒绝后询问 signal.aborted,绝不使用 error.name === 'AbortError' 或 instanceof DOMException;被取消的调用不显示也不记录任何内容;只使用 abort()、aborted 和手动移除的监听器。原因是设备上 expo/fetch 对大多数被中止的请求以 FetchError 拒绝,而 React Native 的 AbortSignal polyfill 没有 throwIfAborted() 或 AbortSignal.abort(),会丢弃传给 abort(reason) 的 reason,并忽略 { signal } 监听器选项,而 Node 上的 Jest 全都有。
- 每个订阅句柄都由其创建者保存并释放:addEventListener、addListener、addListener、subscribe 和 watch() 的返回值被存储并在匹配的 cleanup、useSyncExternalStore subscribe 的返回或 stop() 中移除;绝不在共享 emitter 上 removeAllListeners;只有与 app 同生命周期的组合根监听器可以标注 // app-lifetime: <why>。
- 每个定时器 id 都被保存并清除;暂停可中止:setTimeout、setInterval、requestAnimationFrame 和 requestIdleCallback 的 id 在 cleanup、finally 或 stop() 中交给对应的 clear 函数;暂停使用 await delayOrAbort(ms, signal),绝不使用 new Promise((resolve) => setTimeout(resolve, ms))。
- 屏幕工作仅在屏幕聚焦且 app 活跃时运行:屏幕中的轮询、倒计时、传感器和定位在 useFocusEffect 中运行,并在 useIsAppActive() 为 false 时暂停。服务器轮询使用 useQuery({ refetchInterval, subscribed: useIsFocused() }),绝不手写 interval。
- 每个可能挂起的等待都有截止时间,并取消它放弃的工作:withTimeout(task, afterMs, signal) 返回 timed-out 或 cancelled 作为 Result 失败,并中止任务的 signal;重试有界,通过 delayOrAbort 退避,且只重试可重试的类型;服务器状态重试留给 TanStack Query。
- 每个异步单元都有竞态测试:答案乱序结算、中途卸载、被取消的调用不记录任何内容,且不留下任何东西(jest.getTimerCount() 为 0,fake 的监听器计数回到 0)。
使用方式
工作流按变更分类:处理程序或回调类型(规则 1-2)、effect(3)、重叠调用或 OS 镜像(4)、取消(5)、监听器或定时器(6-7)、屏幕范围工作(8)、等待或重试(9)。首次需要时添加异步工具包,每个都带其测试,路径由其第一行命名:templates/src/core/async/latest-request.ts、templates/src/services/async/delay-or-abort.ts、templates/src/services/async/with-timeout.ts(它导入 src/core/result.ts,由 error-handling-typed-results 创建)。按原样复制,它们在参考应用中通过 tsc、ESLint 和 Jest。
处理程序和回调类型:阅读 references/promises-and-handlers.md,把每个 on* 成员类型化为 => void,让模型拥有 Promise 并暴露 void 意图,包装任何返回 Promise 的引用。
effect(仅用于非服务器状态的工作;服务器读取是 useQuery,交给 networking-and-server-state):阅读 references/effects-and-races.md 第 1-6 节,模仿 examples/use-task-details-model.ts。
重叠调用和 OS 镜像(服务器搜索和过滤是 query key):同一参考第 7-11 节,模仿 examples/use-task-search-model.ts 和 examples/watch-sync-status.ts。
取消、截止时间、重试、端口:阅读 references/cancellation-and-deadlines.md,给每个可放弃的端口成员一个 signal 参数,在适配器中通过 signal.aborted 决定取消。
监听器和定时器:阅读 references/listeners-and-timers.md,了解每个 API 的释放调用、新架构定时器事实以及 iOS 和 Android 的后台行为。屏幕工作使用 useFocusEffect;与 app 同生命周期的组合根监听器标注 // app-lifetime: <why>。
测试:先写竞态测试,来自 references/test-recipes.md;通过删除它保护的守卫来看到每个测试失败。
从仓库根运行检查:npm run typecheck、npm run lint、npx jest <changed tests> --ci,然后 node ${CLAUDE_SKILL_DIR}/scripts/check-async-safety.mjs .。修复每个 FAIL 行并重跑直到 RESULT: PASS。
在设备上,对于目录中标记为 D 的行的变更(abort 代码、后台化、被覆盖的屏幕):在调用完成前,在 iOS 模拟器和 Android 模拟器上运行流程(e2e-maestro)。
适用场景
适用于编写 useEffect、setTimeout、订阅、轮询、重试、取消逻辑等异步代码的 React Native / Expo 场景,尤其是涉及 SQLite 仓库、原生 SDK、OS 读取、设备 API 等非服务器状态的异步工作。
限制
资料未提供该技能的收费、价格、平台支持范围等商业信息。该技能依赖 React Native / Expo 生态,部分规则(如 focus-scoped-work)在没有 expo-router 或 React Navigation 时不适用,在没有 react-native 时标记为 NOT APPLICABLE。检查器有已知限制,详见 references/checker-rules.md。