- Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Issue: asChild with lazy references fails in React 19 + RSC production builds
Description
When using DialogTrigger (or other components) with asChild, the trigger element doesn't render in production builds when React Server Components serialization creates lazy references (Symbol(react.lazy)) for the trigger prop.
The fix in @radix-ui/react-slot@1.2.4 that unwraps lazy components using use() doesn't work because it's applied too late - inside Slot, after DialogTrigger has already received the lazy reference.
Environment
@radix-ui/react-dialog: 1.1.14@radix-ui/react-slot: 1.2.4next: 16.x (App Router with RSC)react: 19.x- Only occurs in production builds (not in development)
Steps to Reproduce
- Create a wrapper component that accepts a
triggerprop and passes it toDialogTrigger:
'use client'; export const NestedDialog = ({ trigger, children }) => { return ( <Dialog> <DialogTrigger asChild>{trigger}</DialogTrigger> <DialogContent>{children}</DialogContent> </Dialog> ); };- Use it from a Server Component where React Flight's deduplication optimization triggers:
// Server Component export default async function Page() { const sharedData = await getData(); // Returns [] return ( <div> <SiblingComponent data={sharedData} /> <NestedDialog trigger={<Button>Open</Button>}> <FormComponent data={sharedData} /> {/* Same reference triggers deduplication */} </NestedDialog> </div> ); }-
Build for production:
npm run build && npm run start -
The button doesn't render.
Root Cause Analysis
We traced the issue through the RSC payload:
Working case (dev or simple pages):
{"trigger": ["$", "button", null, {"className": "...", "children": "Open"}]}Broken case (production with deduplication):
{"trigger": "$L32"} // Lazy referenceWhen trigger arrives at the client component, it has:
{ $$typeof: Symbol(react.lazy), _payload: { status: "resolved_model", value: ..., reason: ... }, _init: [Function] }The fix in Slot (v1.2.4) correctly detects this:
if (isLazyComponent(children) && typeof use === "function") { children = use(children._payload); }But it doesn't work because:
DialogTriggerreceives the lazy reference aschildrenDialogTriggermay process/wrap children before passing toSlot- By the time
Slotreceives it, the structure has changed
Workaround
Unwrap lazy references before passing to DialogTrigger:
import { use } from 'react'; function unwrapLazy(element: ReactNode): ReactNode { const el = element as any; if ( el?.$$typeof === Symbol.for('react.lazy') && el?._payload !== null && typeof el?._payload === 'object' && 'then' in el._payload ) { return use(el._payload); } return element; } export const NestedDialog = ({ trigger, children }) => { const unwrappedTrigger = unwrapLazy(trigger); return ( <Dialog> <DialogTrigger asChild>{unwrappedTrigger}</DialogTrigger> <DialogContent>{children}</DialogContent> </Dialog> ); };Suggested Fix
The lazy unwrapping logic should be applied in DialogTrigger (and similar components like TooltipTrigger, PopoverTrigger, etc.) before any processing of children, not just in Slot.
Alternatively, components using asChild should document that wrapper components need to handle lazy references themselves.
Related Issues
- React #32392 -
cloneElementwith async server components - Next.js #82527 - cloneElement not working in production builds
- Radix #2537 - Plans for asChild with cloneElement being legacy
Additional Context
React maintainers have indicated that cloneElement is "soft deprecated" due to conflicts with RSC optimization strategies. However, until an alternative pattern for asChild exists, this lazy reference handling is necessary for production RSC builds.