RSC 边界的隐形雷:Client 组件作为 render prop 传入时 type 变 undefined
给一个按钮加了 whileTap 动画,顺手给它加了 'use client'。随后刷新页面,控制台炸了。SSR 输出的 HTML 里一切正常,next build 显示 ○ Static,唯独浏览器端抛出:
Element type is invalid: expected a string (for built-in components)
or a class/function (for composite components) but got: undefined.
Check the render method of `DialogTrigger`.
DialogTrigger 是 Base UI 的组件,和刚才改动的按钮没有半毛钱直接关系。排查方向瞬间偏掉。
现象
- 页面是 Server Component(无
'use client'),导入了一个刚标记为'use client'的<Button>。 - 直接在页面里写
<Button>文字</Button>→ 正常。 - 把它作为元素值传进另一个 Client 组件的 slot → 浏览器运行时崩溃:
// 这三种写法都中招
<DialogTrigger render={<Button variant="outline">打开</Button>} /> // Base UI
<Tooltip trigger={<Button />} />
<Item startContent={<Button />} />next build仍然○ Static,curl拿到的 SSR HTML 里元素存在。崩溃只在客户端。- 在
<Button>内部加日志:直接用的那几处打印出来,render prop 那处永远不打印——element.type在被传递前就已经是undefined,React 甚至没机会调用它。
还有一个变体:当 render prop 是原生宿主元素(<a />、<button />)而不是 Client 组件时,报错信息不同:
Cannot read properties of undefined (reading 'className')
接收方在 cloneElement 时读了 render.props.className,而跨边界传递的元素 .props 是 undefined,所以在接收方源码某行爆炸,而不是 "Element type is invalid"。不要被不同的报错信息骗去追 className 或 cn 的 bug,根因是同一个。
根因
Next.js App Router 的 RSC 运行时中,Server Component 引用 'use client' 组件时,持有的是一个客户端引用(client reference),即模块指针代理,而非真实的函数。
- 直接作为 JSX 子节点使用:React 的 Flight 运行时会识别并解析这个引用,客户端正确还原为真实类型。
- 塞进一个元素值 prop,再由另一个 Client 组件通过
cloneElement或 render slot 来消费:引用在这个位置无法被还原,element.type在客户端落地为undefined,于是React.createElement(undefined)触发 invalid element 错误,错误栈指向执行 clone 的那个包装器。
children 是 React 特殊处理的例外,始终可以穿越边界。其他任意元素值 prop(render / trigger / icon / startContent)没有同等待遇。
next build 预渲染和 SSR 阶段可以正常解析 client reference,生成有效 HTML。崩溃发生在浏览器 hydration / 客户端渲染时。这个 SSR/CSR 不对称,是 RSC 边界问题的典型告警特征。
不要怀疑第三方库(Base UI、Radix、HeroUI)的 render slot 实现,也不要删 .next 缓存——问题不在那里。
正解
把使用元素 prop 的那段 JSX 抽成独立的 Client island,让 <Button> 元素在 Client 模块内部创建:
// dialog-demo.tsx
'use client';
import { Button, Dialog, DialogTrigger, DialogContent } from '@your/ui';
export function DialogDemo() {
return (
<Dialog>
<DialogTrigger render={<Button variant="outline">打开</Button>} />
<DialogContent title="详情">…</DialogContent>
</Dialog>
);
}// page.tsx — 保持 Server Component,不加 'use client'
import { Button } from '@your/ui';
import { DialogDemo } from '@/components/dialog-demo';
export default function Page() {
return (
<>
<Button>直接用,没问题</Button>
<DialogDemo />
</>
);
}next build 路由依然显示 ○ Static,浏览器控制台零报错。
核心原则:元素值 prop 必须在创建它的模块和消费它的组件都是 Client 代码时才安全。把这一段抽成薄薄的 island 组件,两边都在 Client 图里,RSC 边界问题自然消失。
快速诊断
怀疑是 RSC 边界问题时,先做一个一分钟实验:临时给出问题的页面加 'use client'。若报错立刻消失,确认是边界问题,随即改用 island 方案并移除页面上的 'use client'。
这个实验可以把原本需要五轮迭代的 vendor 源码追查,压缩到一次测试。
一句话外卖
children能跨 RSC 边界,但render={<ClientComp/>}不能——元素值 prop 必须在纯 Client 上下文内创建和消费。