返回博客

HeroUI v3 Tabs.Indicator 炸出 SharedElement 错误:两个约束互斥的死局

照着 HeroUI v3 的 Tabs 命名空间老老实实拼 Tabs.ListContainer > Tabs.List + Tabs.Indicator,想做一个带滑动 pill 的 segmented control——结果页面直接崩掉,浏览器扔出一行红字:

Error: <SharedElement> must be rendered inside a <SharedElementTransition>
  at SelectionIndicator (react-aria-components/private/SelectionIndicator.mjs)

错误信息技术上是对的,但 HeroUI 文档里根本没有 SharedElementTransition 这个东西。更诡异的是:把 Tabs.Indicator 挪进 Tabs.List 里,报错消失了,但指示器也消失了——DOM 里根本没渲染出来。两种写法各有一种死法,死得悄无声息。

现象

// 写法 A:抛 SharedElement 错误
<Tabs.ListContainer>
  <Tabs.List>{tabs.map(…)}</Tabs.List>
  <Tabs.Indicator />
</Tabs.ListContainer>
 
// 写法 B:无报错,但 Indicator 根本不渲染
<Tabs.List>
  {tabs.map(tab => <Tabs.Tab key={tab.id} id={tab.id}>{tab.name}</Tabs.Tab>)}
  <Tabs.Indicator />
</Tabs.List>

两种写法都来自对 HeroUI slot 命名空间的"合理猜测",都走不通。

根因

跨三个包的调用链揭示了这是一个结构性互斥:

第一层Tabs.Indicator 直接渲染 React Aria 的 <SelectionIndicator />,没有包任何额外 context。

第二层SelectionIndicator 内部渲染 <SharedElement name="SelectionIndicator" />,而 SharedElement 要求祖先树上必须存在 <SharedElementTransition> provider,否则直接抛错,无 fallback。

第三层SharedElementTransition 确实存在——但它被 React Aria 的 TabList 在内部挂载,包在 CollectionRoot 外面(Tabs.mjs:148-155)。所以从上下文角度看,Tabs.Indicator 必须在 TabList 内部才能拿到这个 context。

死锁出现了TabList 通过 React Aria 的 Collection 系统渲染子节点,Collection 只接受注册为叶节点的 Tab 类型,任何其他节点(包括 Tabs.Indicator)都被静默丢弃——没有 DOM,没有报错,就是消失。

坑:Tabs.Indicator 在 HeroUI v3.1.0 中无论放哪里都无法正常工作
  • 放在 TabList 外面:拿不到 SharedElementTransition context → 抛错
  • 放在 TabList 里面:被 Collection 系统当成非法节点静默过滤 → 渲染为空

这两个约束(必须在 TabList 内 vs. TabList 只接受 Tab 叶节点)在 HeroUI v3.1.0 + react-aria-components 1.13+ 中是互斥的,没有"正确的挂载位置"。

正解

方案 A:放弃 Tabs.Indicator,用 CSS 表达 active 状态(推荐)

把 pill 背景移到每个 Tabs.Tab 本身,通过 React Aria 注入的 data-selected 属性控制样式。失去跨 tab 动画,视觉效果完全一致:

const heroTabsList =
  'inline-flex w-fit gap-1 rounded-lg bg-muted p-1'
 
const heroTabsTab =
  'relative flex h-8 cursor-pointer items-center justify-center rounded-md px-3 ' +
  'text-sm font-medium text-muted-foreground outline-none transition-colors ' +
  'data-[selected=true]:bg-background data-[selected=true]:text-foreground ' +
  'data-[selected=true]:shadow-sm'
 
<Tabs aria-label="…" selectedKey={selected} onSelectionChange={setSelected}>
  <Tabs.List className={heroTabsList}>
    {items.map(({ id, name }) => (
      <Tabs.Tab key={id} id={id} className={heroTabsTab}>{name}</Tabs.Tab>
    ))}
  </Tabs.List>
  <Tabs.Panel id="…">…</Tabs.Panel>
</Tabs>

不需要 Tabs.ListContainer,不需要 Tabs.Indicator,TypeScript 类型完全合法。

方案 B:把 Tabs.Indicator 放进每个 Tab 内部

如果必须要滑动动画,可以在每个 Tabs.Tabchildren 里各放一个 Tabs.Indicator。React Aria 的 SharedElement 系统会把它们坍缩成一个 DOM 节点并在 tab 切换时做动画。这个位置能拿到 SelectionIndicatorContextTabs.mjs:205),Collection 也不会丢掉它(因为它在叶节点内部,不是叶节点的兄弟):

<Tabs.List className={heroTabsList}>
  {items.map(({ id, name }) => (
    <Tabs.Tab key={id} id={id} className={heroTabsTab}>
      <Tabs.Indicator className="absolute inset-0 -z-10 rounded-md bg-background shadow-sm" />
      {name}
    </Tabs.Tab>
  ))}
</Tabs.List>

注意 Tabs.Indicator 需要 position: absolute,必须作为子元素配合 inset-0 + -z-10 才能覆盖 Tab 内容背后。热更新后第一次切换可能出现位置闪烁(SharedElement 初始化抖动),遇到就退回方案 A。

正解:不要用文档暗示的 TabList 外挂法

两种可用方案都绕开了 Tabs.ListContainer > Tabs.Indicator 这个设计上的死局:

  • 方案 A(推荐):CSS data-[selected=true] 表达 active,零运行时依赖
  • 方案 B:每个 Tab 内放 Indicator,正确落在 SharedElementTransition context 里

修完后用 git grep -nE "Tabs\.Indicator|Tabs\.ListContainer" src/ 扫全库,同款用法各有一种死法。

从 NextUI v2 迁移过来的注意

NextUI v2 的 Tabs 用自己的内部 cursor classNames slot,和 React Aria SharedElement 无关。 HeroUI v3 改写后用 React Aria 接管,Tabs.Cursor 这个命名空间直接消失,替换成了(有 bug 的)Tabs.Indicator。 v2 的肌肉记忆在 v3 里会把你送进这个坑。

一句话外卖

当第三方组件库把 UI primitive 换成另一套框架的内部 primitive 时,文档暴露的 API 命名空间可能掩盖掉底层 context 要求与渲染过滤机制之间的互斥——遇到"放哪里都不对"的组件,先读源码找 context provider 的真实挂载点。