返回博客

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 仍然 ○ Staticcurl 拿到的 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,而跨边界传递的元素 .propsundefined,所以在接收方源码某行爆炸,而不是 "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)没有同等待遇。

坑:SSR 正常不代表客户端正常

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,浏览器控制台零报错。

正解:把 slot 用法沉到 Client island,不动页面

核心原则:元素值 prop 必须在创建它的模块消费它的组件都是 Client 代码时才安全。把这一段抽成薄薄的 island 组件,两边都在 Client 图里,RSC 边界问题自然消失。

快速诊断

怀疑是 RSC 边界问题时,先做一个一分钟实验:临时给出问题的页面'use client'。若报错立刻消失,确认是边界问题,随即改用 island 方案并移除页面上的 'use client'

这个实验可以把原本需要五轮迭代的 vendor 源码追查,压缩到一次测试。

一句话外卖

children 能跨 RSC 边界,但 render={<ClientComp/>} 不能——元素值 prop 必须在纯 Client 上下文内创建和消费。