Render Prop Leakage in MessagePartPrimitive.Text#
Summary: When the render prop is passed to MessagePartPrimitive.Text and the component default is a string literal ("span"), the render element is sent to the DOM as a raw HTML attribute instead of being used to swap the rendered element. This manifests as the text staying inside a <span> regardless of the supplied render element, and a render="[object Object]" attribute appearing on the DOM node.
How the Bug Manifests#
MessagePartPrimitive.Text accepts a render prop (inherited from Primitive.span) that is meant to swap the rendered element using the asChild/Slot composition pattern. However, in the buggy version, the component destructures and immediately passes ...rest — including render — down onto a plain string-tag component (e.g., "span"), which has no knowledge of the render convention .
render={<mark />} → <span render="[object Object]">text</span> ❌
React passes unknown props on intrinsic elements to the DOM, so render becomes a visible (and invalid) HTML attribute. The intended element (<mark>) is never mounted.
The Primitive.span wrapper — defined in Primitive.tsx — is what actually intercepts the render prop and converts it to the Radix asChild + cloneElement pattern . Bypassing it by falling back to the string "span" skips this entire layer.
Root Cause#
In MessagePartText.tsx, the component prop defaults to the string "span":
({ smooth = true, component: Component = "span", ...rest }, forwardedRef)
When component is not explicitly set, Component is a plain HTML tag string. Spreading ...rest onto it forwards every unrecognized prop (including render) directly to the DOM — bypassing the withRenderProp adapter entirely .
The Fix (PR #6960)#
PR #6960 fixes this with two minimal changes to MessagePartText.tsx:
-
Change
import typeto a value import soPrimitiveis available at runtime:- Before:
import type { Primitive } from "../../utils/Primitive"; - After:
import { Primitive } from "../../utils/Primitive";
- Before:
-
Replace the string literal default with
Primitive.span:- Before:
component: Component = "span" - After:
component: Component = Primitive.span
- Before:
This ensures the default path routes through withRenderProp, which intercepts render, converts it to asChild, and uses Radix's Slot for prop/ref merging . Explicit component values (e.g., component="p") are unaffected — they bypass Primitive.span by design and do not support render .
When Does This Bite You?#
| Usage | Affected? |
|---|---|
<MessagePartPrimitive.Text /> (default) | No — render not passed |
<MessagePartPrimitive.Text render={<mark />} /> | Yes — pre-fix, render leaks to DOM |
<MessagePartPrimitive.Text component="p" render={<mark />} /> | Yes — string component bypasses Primitive.span; render leaks |
<MessagePartPrimitive.Text component={Primitive.span} render={<mark />} /> | No — Primitive.span handles render correctly |
Key Files#
MessagePartText.tsx— the component implementation; see line 54 for the render/component interactionPrimitive.tsx— defineswithRenderPropand thePrimitive.*wrappers; see lines 67–110 for the render→asChild conversion logic- PR #6960 — the fix, reproduction case, and test coverage