返回博客

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.pushstartTransition 产生了竞态?排查了一圈,没有任何 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 APIdocument.startViewTransition())的标准 reject 消息。当一个视图过渡被跳过时,transition.readytransition.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/
# 均无结果

触发跳过的条件(任意一条满足即跳过):

  1. 上一个过渡还没结束时又调用了 startViewTransition()("was started" 变体)
  2. 同一帧内多个元素使用了相同的 view-transition-name
  3. 过渡设置期间页面变为 hidden(切 tab)
  4. 回调抛异常或返回的 Promise reject
  5. 页面从 BFCache 恢复时触发
坑:logger 放大陷阱让 dev overlay 出现红框

真正让排查陷入迷雾的是"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> — 点击主题切换时调用 startViewTransitionready.then() 没有 .catch
  • motion v12+ / framer-motion 新版 — layoutId 动画内部走了 View Transitions
  • next.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