AbortError: Transition was skipped 不是 React 19 的锅,是 View Transitions API
某天打开 Next.js 15 + React 19 项目,dev overlay 红框出现:Runtime AbortError: Transition was skipped。没有明显的栈帧,没有稳定复现路径,偶发,在快速操作或切换 tab 时出现。
第一反应:React 19 useTransition 被打断了?还是 router.push 和 startTransition 产生了竞态?排查了一圈,没有任何 startTransition 的调用能对上号。
现象
- Next.js dev overlay 红框:
Runtime AbortError — Transition was skipped - 变体:
InvalidStateError: View transition was skipped because document visibility state is hidden - 偶发,快速连击、切换 tab、HMR 热重载时触发
- 生产构建无红框(无 overlay),但 Sentry / error tracker 里满是这个错误,用户侧没有任何感知异常
根因
这条错误信息和 React 19 没有任何关系。
"Transition was skipped" 是 W3C View Transitions API(document.startViewTransition())的标准 reject 消息。当一个视图过渡被跳过时,transition.ready 和 transition.finished 会以 DOMException { name: "AbortError" } 拒绝。如果没有 .catch,就变成未处理的 Promise rejection。
验证方式:
# React 19 源码里根本没有这条字符串
grep -r "Transition was skipped" node_modules/react/cjs/
grep -r "Transition was skipped" node_modules/react-dom/cjs/
# 均无结果触发跳过的条件(任意一条满足即跳过):
- 上一个过渡还没结束时又调用了
startViewTransition()("was started" 变体) - 同一帧内多个元素使用了相同的
view-transition-name - 过渡设置期间页面变为 hidden(切 tab)
- 回调抛异常或返回的 Promise reject
- 页面从 BFCache 恢复时触发
真正让排查陷入迷雾的是"logger 放大"链条:
1. 某处触发 View Transition skip
↓ transition.ready 以 AbortError reject
2. 没有 .catch → window unhandledrejection 事件触发
↓
3. 项目全局 logger 监听了 unhandledrejection,调用 logger.error(...)
↓
4. logger.error 内部调用 console.error(...)
↓
5. Next.js 15 dev overlay 拦截 console.error → 升级为红框
结果:dev overlay 显示 Runtime AbortError,栈帧顶部是 logger 文件,不是真正调用 startViewTransition 的地方。这导致你根本看不见真实的触发点,反而以为是 React / Next.js 的问题。
排查真实触发源:
# 1. 自己的代码
rg -n 'startViewTransition|view-transition-name' src/
# 2. 动画库内部(motion / framer-motion / magic-ui 等新版本有此路径)
rg -n 'startViewTransition' node_modules/motion node_modules/framer-motion \
node_modules/@heroui 2>/dev/null
# 3. Next.js 实验性 flag
rg -n 'viewTransition' next.config.*
# 4. React 19 实验性 <ViewTransition> 组件
rg -n 'react.unstable_ViewTransition' src/常见阳性来源:
magic-ui的<AnimatedThemeToggler>— 点击主题切换时调用startViewTransition,ready.then()没有.catchmotionv12+ /framer-motion新版 — layoutId 动画内部走了 View Transitionsnext.config里开启了experimental.viewTransition: true- React 19 实验性
<ViewTransition>组件(SSR 注入的$RV脚本,遇到vt-*属性时自动触发)
正解
Fix A — 在 call site 加 .catch(必须做)
const transition = document.startViewTransition(() => updateDom())
transition.ready.catch(err => {
if (err?.name === 'AbortError') return // skip 是正常行为,不是 bug
throw err
})Fix B — logger 过滤 AbortError(必须做)
window.addEventListener('unhandledrejection', e => {
const reason = e.reason as { name?: string } | null | undefined
if (reason?.name === 'AbortError') {
e.preventDefault() // 阻止浏览器自己的 console.warn
return // 不调用 logger.error → 不触发 overlay
}
// ...正常上报
})两个 fix 解决不同问题:A 阻止 rejection 产生;B 阻止未来任何来源的 AbortError(fetch cancel、Web Animations 取消、浏览器插件等)污染 dev overlay 和 error tracker。
如果是重入问题(快速连击),加防重入守卫:
let pending: ViewTransition | null = null
function safeStartTransition(fn: () => void) {
if (pending) return pending // 丢弃新触发
const t = document.startViewTransition(fn)
pending = t
t.finished.finally(() => { pending = null })
return t
}如果是 view-transition-name 重名,同一帧内名字必须唯一:
// 错误:列表里每个 card 都叫同一个名字
<div style={{ viewTransitionName: 'card' }} />
// 正确:绑定 id 保证唯一
<div style={{ viewTransitionName: `card-${item.id}` }} />一句话外卖
"Transition" 这个词同时活在 React Concurrent Mode 和 W3C View Transitions API 两个命名空间里;dev overlay 没有栈帧时,先怀疑是 logger 放大了一个本不是错误的 rejection,再 grep
startViewTransition,不要凭直觉去改useTransition。