Skip to content

fix(ui): forward refs in Button so Radix asChild slots work#929

Merged
tannerlinsley merged 1 commit into
mainfrom
taren/adoring-merkle-373a75
May 19, 2026
Merged

fix(ui): forward refs in Button so Radix asChild slots work#929
tannerlinsley merged 1 commit into
mainfrom
taren/adoring-merkle-373a75

Conversation

@tannerlinsley
Copy link
Copy Markdown
Member

@tannerlinsley tannerlinsley commented May 19, 2026

Summary

  • Wrap Button in React.forwardRef so refs reach the underlying DOM element when Radix Slot composes them onto the child.
  • Switch ButtonProps to ComponentPropsWithRef so consumers' ref={} type-checks.

Why

The Copy Page Dropdown on docs/blog pages had stopped appearing. DOM was being rendered, but the Radix popper wrapper was stuck at transform: translate(0px, -200%) — the off-screen fallback Radix uses while isPositioned is false.

Root cause: CopyPageDropdown is the only <DropdownTrigger> consumer that nests our custom Button (every other consumer passes a native DOM element). With a plain function component, {...props} spread into React.createElement didn't carry the ref Radix Slot composed via cloneElement onto the trigger. PopperAnchor's local ref stayed null, onAnchorChange(null) was the only call, Floating UI's computePosition never ran (no reference element), and isPositioned stayed false forever.

Verified: dropdown now opens with real coordinates (translate(635px, 490px) in a 1440x900 viewport), right-aligned to the trigger and just below it.

Test plan

  • pnpm test:tsc — clean
  • pnpm test:lint — 0 errors (10 pre-existing warnings unrelated)
  • Manual: opened /blog/announcing-tanstack-query-v5, clicked the chevron next to "Copy page" — menu renders with all 5 items in the expected position
  • Sanity-check other Radix dropdowns (search modal, breadcrumb TOC, brand context menu, auth user menu) — they all use native DOM elements as triggers so should be unaffected

Summary by CodeRabbit

  • Refactor
    • Enhanced Button component's ref handling capabilities and type definitions for improved component integration.

Review Change Stack

The custom Button was a plain function component, so when used as a child
of `<DropdownMenu.Trigger asChild>` the ref Radix Slot composed onto it
never reached the underlying `<button>` DOM node. Radix's PopperAnchor
ended up with a null anchor, Floating UI's computePosition never ran, and
the CopyPageDropdown wrapper stayed at the off-screen fallback
`translate(0px, -200%)` so the menu was invisible.

Wrap Button in React.forwardRef and pass the ref through to
createElement; switch ButtonProps to ComponentPropsWithRef so `ref={}`
type-checks for consumers.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

The Button component is updated to forward refs to its underlying HTML element. ButtonProps now uses React.ComponentPropsWithRef for ref-aware prop omission, a new ButtonInnerProps type is introduced, and the component is wrapped with React.forwardRef. The ref is threaded through React.createElement and props are destructured as rest rather than spread directly.

Changes

Button ref forwarding

Layer / File(s) Summary
Ref forwarding implementation and type contract
src/ui/Button.tsx
ButtonProps is updated to use ComponentPropsWithRef for proper ref-aware typing. ButtonInnerProps type is introduced and the exported Button component is converted to React.forwardRef with matching generics. The ref is passed into React.createElement alongside rest props that are destructured from the forwardRef callback parameters.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A button now opens a door so wide,
With refs held close and props unified,
React.forwardRef in the HTML's frame,
The Button component's never the same! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(ui): forward refs in Button so Radix asChild slots work' clearly and specifically describes the main change—wrapping Button in React.forwardRef to enable Radix Slot composition. It directly reflects the primary objective and the key fix implemented.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch taren/adoring-merkle-373a75

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ui/Button.tsx`:
- Around line 102-107: The implementation uses React.forwardRef and a
monomorphic ref type causing unsafe casts; change Button to the React 19
polymorphic pattern by removing React.forwardRef and making Button a generic
function component that accepts ref as a normal prop. Update ButtonInnerProps
(and any type alias used by ButtonComponent) to be generic over TElement so the
component signature matches ButtonComponent, accept an optional ref prop (e.g.,
ref?: React.Ref<TElement>) in the props, and remove the unsafe casts currently
applied to props and to the exported component (the "props as ..." and "as
unknown as ButtonComponent" usages). Ensure the exported Button has the generic
polymorphic type (ButtonComponent) without using forwardRef.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86e8385d-8cbe-463b-a796-c9435dbb6ac1

📥 Commits

Reviewing files that changed from the base of the PR and between 231bc91 and 32d202a.

📒 Files selected for processing (1)
  • src/ui/Button.tsx

Comment thread src/ui/Button.tsx
Comment on lines +102 to +107
type ButtonInnerProps = ButtonOwnProps & Record<string, unknown>

export const Button: ButtonComponent = React.forwardRef<
HTMLElement,
ButtonInnerProps
>(function Button(props, ref) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In React 19 + TypeScript, what is the recommended way to type a polymorphic component with an asprop and forwarded refs without usingas unknown as casts?

💡 Result:

In React 19, the ref prop is now treated as a standard prop, which simplifies the implementation of polymorphic components by removing the need for forwardRef [1][2][3]. To create a type-safe polymorphic component without using as unknown as casts, you should define a generic component that accepts an as prop and destructures ref directly from the props object [2][3]. The recommended approach involves: 1. Defining a generic type for your component props that extends React.ElementType [4][5]. 2. Using React.ComponentPropsWithRef<C> to ensure the component correctly inherits the props and ref type of the element specified by the as prop [6][4]. 3. Destructuring ref and as from the props and passing them directly to the underlying element [2]. Example Pattern: type PolymorphicProps<C extends React.ElementType, Props = {}> = Props & { as?: C } & Omit<React.ComponentPropsWithRef, keyof Props | 'as'>; export const PolymorphicComponent = ({ as, ref,...props }: PolymorphicProps<C, { children?: React.ReactNode }>) => { const Component = as || 'div'; return <Component ref={ref} {...props} />; }; By destructuring ref as a standard prop, you avoid the complex type signatures previously required by forwardRef [2][3]. This pattern allows TypeScript to correctly infer the element type and its associated ref, maintaining full type safety without requiring type assertions [4]. If you are working in a codebase that still requires forwardRef for compatibility, you can continue to use it, but it is no longer necessary for new components in React 19 [1][2].

Citations:


🏁 Script executed:

# Check React version
fd -e json -e ts -e js package.json tsconfig.json | head -5

Repository: TanStack/tanstack.com

Length of output: 166


🏁 Script executed:

# Read the Button.tsx file to see full context
cat -n src/ui/Button.tsx | sed -n '95,150p'

Repository: TanStack/tanstack.com

Length of output: 1636


🏁 Script executed:

# Check if there are type definitions for ButtonProps and ButtonComponent
rg -A 5 "type ButtonComponent|type ButtonProps|interface ButtonProps" src/ui/Button.tsx

Repository: TanStack/tanstack.com

Length of output: 493


🏁 Script executed:

# Check React version in package.json
rg "react" package.json -A 1 -B 1 | head -20

Repository: TanStack/tanstack.com

Length of output: 764


🏁 Script executed:

# Check if ButtonOwnProps is properly generic
rg -B 5 "type ButtonOwnProps" src/ui/Button.tsx

Repository: TanStack/tanstack.com

Length of output: 258


🏁 Script executed:

# Check ButtonInnerProps usage and ButtonOwnProps scope
rg "ButtonOwnProps" src/ui/Button.tsx

Repository: TanStack/tanstack.com

Length of output: 366


🏁 Script executed:

# Find React version more explicitly
cat package.json | jq '.dependencies.react, .devDependencies.react' 2>/dev/null || rg '"react":\s*"' package.json

Repository: TanStack/tanstack.com

Length of output: 79


🏁 Script executed:

# Get full ButtonOwnProps and ButtonProps definitions
rg -B 2 -A 15 "type ButtonOwnProps" src/ui/Button.tsx

Repository: TanStack/tanstack.com

Length of output: 630


🏁 Script executed:

# Check if the component is being used polymorphically in the codebase
rg "Button\s+as=" --type tsx --type ts | head -10

Repository: TanStack/tanstack.com

Length of output: 93


Update to use React 19's simpler polymorphic pattern without forwardRef.

The component has a polymorphic type contract (ButtonComponent is generic over TElement) but the implementation uses monomorphic forwardRef<HTMLElement, ButtonInnerProps>, creating a type mismatch that requires unsafe casts (line 117: props as ..., line 145: as unknown as ButtonComponent). Since the codebase is on React 19.2.3, ref can be treated as a standard prop, eliminating the need for forwardRef entirely and removing the type coupling issue.

♻️ Suggested direction
-type ButtonInnerProps = ButtonOwnProps & Record<string, unknown>

-export const Button: ButtonComponent = React.forwardRef<
-  HTMLElement,
-  ButtonInnerProps
->(function Button(props, ref) {
+const ButtonImpl = <TElement extends React.ElementType = 'button'>(
+  props: ButtonProps<TElement> & { ref?: React.ForwardedRef<any> }
+) => {
+  const { ref, as, children, variant = 'primary', color = 'blue', size, rounded, className, ...rest } = props
-  const {
-    as,
-    children,
-    variant = 'primary',
-    color = 'blue',
-    size,
-    rounded,
-    className,
-    ...rest
-  } = props as ButtonOwnProps & Record<string, unknown>
   const Component = as || 'button'
   const resolvedSize = size ?? getDefaultSize(variant)
   const resolvedRounded = rounded ?? getDefaultRounded(resolvedSize)

   const colorStyles =
     variant === 'primary'
       ? primaryColorStyles[color]
       : variant === 'icon'
         ? iconColorStyles[color]
         : ''

   return React.createElement(
     Component,
     {
       ref,
       className: twMerge(
         baseStyles,
         variantStyles[variant],
         sizeStyles[resolvedSize],
         roundedStyles[resolvedRounded],
         colorStyles,
         className,
       ),
       ...rest,
     },
     children,
   )
-}) as unknown as ButtonComponent
+}
+
+export const Button = ButtonImpl as ButtonComponent

Also applies to: 117-117, 145-145

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/Button.tsx` around lines 102 - 107, The implementation uses
React.forwardRef and a monomorphic ref type causing unsafe casts; change Button
to the React 19 polymorphic pattern by removing React.forwardRef and making
Button a generic function component that accepts ref as a normal prop. Update
ButtonInnerProps (and any type alias used by ButtonComponent) to be generic over
TElement so the component signature matches ButtonComponent, accept an optional
ref prop (e.g., ref?: React.Ref<TElement>) in the props, and remove the unsafe
casts currently applied to props and to the exported component (the "props as
..." and "as unknown as ButtonComponent" usages). Ensure the exported Button has
the generic polymorphic type (ButtonComponent) without using forwardRef.

@tannerlinsley tannerlinsley merged commit 3a136f2 into main May 19, 2026
9 checks passed
@tannerlinsley tannerlinsley deleted the taren/adoring-merkle-373a75 branch May 19, 2026 22:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant