Skip to content

asChild with lazy references fails in React 19 + RSC production builds #3776

@sleitor

Description

@sleitor

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.4
  • next: 16.x (App Router with RSC)
  • react: 19.x
  • Only occurs in production builds (not in development)

Steps to Reproduce

  1. Create a wrapper component that accepts a trigger prop and passes it to DialogTrigger:
'use client'; export const NestedDialog = ({ trigger, children }) => { return ( <Dialog> <DialogTrigger asChild>{trigger}</DialogTrigger> <DialogContent>{children}</DialogContent> </Dialog> ); };
  1. 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> ); }
  1. Build for production: npm run build && npm run start

  2. 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 reference

When 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:

  • DialogTrigger receives the lazy reference as children
  • DialogTrigger may process/wrap children before passing to Slot
  • By the time Slot receives 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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions