React Offscreen 重连时 ref.current 变游离节点,el.style 直接 undefined 崩溃
做 3D 卡片堆叠动效时,我在 useEffect 里对一组 ref 逐个写 el.style.zIndex、el.style.transform。本地单元测试全绿,正式预览环境也没问题——直到用户切换标签页再切回来,控制台炸出一条:
TypeError: Cannot set properties of undefined (setting 'zIndex')
调用栈第一眼看不出所以然:reconnectPassiveEffects → recursivelyTraverseReconnectPassiveEffects → commitHookPassiveMountEffects。这不是我的业务代码,是 React 内部的 Offscreen 重连路径。
现象
- 组件首次挂载、正常操作全部正常,切换标签页或触发
<Activity>隐藏再显示后必崩。 - 错误行就是
el.style.zIndex = String(...)这种普通赋值。 - 代码里明明有
if (!el) return守卫,el 非空,但el.style却是undefined。 - jsdom / Vitest 单测:永远不复现,因为 jsdom 根本不跑 Offscreen 重连路径,而且 jsdom 里游离节点也保留
.style。
根因
React 的 Activity/Offscreen 机制在把子树从隐藏变为可见时,会对其中所有组件重新触发 passive effect(即 useEffect),这条路径叫 reconnectPassiveEffects。
关键陷阱:重连时 ref.current 拿到的节点已被从 DOM 树中摘除,它是一个"游离节点"(detached node)。游离节点是真值(!!el === true),所以 if (!el) return 放行了它。但浏览器对游离节点的 .style 访问行为不统一——某些情况下 .style 会是 undefined,随后对它赋值就直接 TypeError。
Offscreen 重连后,ref.current 可能指向一个游离节点。游离节点通过 if (!el) 检查,但 el.style 可能是 undefined。只守 null 是不够的,必须同时守 style。
StrictMode 双重挂载(开发模式)会走相同路径,所以在 dev 环境切一次 tab 就能稳定复现——这反而是好事,让问题更早暴露。
正解
把所有 el.style.* 写入点的守卫从 !el 改为 !el?.style:
- if (!el) return;
+ if (!el?.style) return; // 游离节点:el 为真值但 .style 是 undefined,直接跳过
el.style.zIndex = String(slot.zIndex);
el.style.transform = `...`;一个 effect 里往往有多处写 style 的位置(初始布局、动画循环、cleanup),每一处都要改,不能只改入口处。
游离节点没有可操作的样式上下文,跳过它是完全正确的——正常挂载的节点永远拥有 .style,所以这个守卫对正常路径零影响。
同理,el.classList、el.getBoundingClientRect() 在游离节点上同样危险。如果你需要精确判断"是否挂载到文档",可以用 el?.isConnected;但对 style 写入场景,el?.style 已经是最小必要守卫。
排查路径
- 看报错的属性名是不是 style prop(
zIndex、transform、opacity……)。 - 搜调用栈,看有没有
reconnectPassiveEffects——有就是 Offscreen/Activity 重连路径。 - jsdom 跑不出来是正常的,切到真实浏览器(Playwright 打开 dev server,切一次 tab)验证。
一句话外卖
在
useEffect里写el.style,守卫要用!el?.style,不能只守!el——Offscreen 重连会给你一个游离节点,它是真值但没有活的样式上下文。