Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ref-as-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/redact': patch
---

Keep `ref` in props for `createElement` and `cloneElement`, matching React 19's ref-as-prop behavior. Libraries that clone an element with a merged ref (Base UI's `render` prop, for example) lost the ref entirely, so the cloned function component never received it and ref-dependent behavior — tooltips, popovers, anything anchored to a trigger — silently never activated.
4 changes: 3 additions & 1 deletion packages/redact/src/react/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export const Fragment = REACT_FRAGMENT_TYPE as unknown as (props: {
ref?: Ref<FragmentInstance>
}) => ReactElement

const RESERVED_PROPS: Record<string, 1> = { key: 1, ref: 1, __self: 1, __source: 1 }
// React 19 passes `ref` through as a regular prop — only these are held back.
const RESERVED_PROPS: Record<string, 1> = { key: 1, __self: 1, __source: 1 }

export function createElement(
type: any,
Expand Down Expand Up @@ -63,6 +64,7 @@ export function cloneElement(
const defaultProps = element.type?.defaultProps
for (const k in config) {
if (RESERVED_PROPS[k] || !Object.prototype.hasOwnProperty.call(config, k)) continue
if (k === 'ref' && config.ref === undefined) continue
props[k] = config[k] === undefined && defaultProps ? defaultProps[k] : config[k]
}
}
Expand Down
50 changes: 50 additions & 0 deletions tests/element-ref-prop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it } from 'vitest'
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'

const cleanups: Array<() => void> = []
function setup() {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
cleanups.push(() => {
flushSync(() => root.unmount())
container.remove()
})
return { container, render: (node: React.ReactNode) => flushSync(() => root.render(node)) }
}
afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup()
})

function Child(props: { id?: string; ref?: React.Ref<HTMLSpanElement> }) {
return <span {...props} />
}

describe('ref as a prop', () => {
it('keeps ref in props on createElement', () => {
const ref = React.createRef<HTMLSpanElement>()
expect(React.createElement(Child, { ref }).props.ref).toBe(ref)
})

it('keeps ref in props on cloneElement', () => {
const ref = React.createRef<HTMLSpanElement>()
const clone = React.cloneElement(<Child />, { ref })
expect(clone.props.ref).toBe(ref)
})

it('leaves the original ref alone when cloning without one', () => {
const ref = React.createRef<HTMLSpanElement>()
const clone = React.cloneElement(<Child ref={ref} />, { id: 'cloned' })
expect(clone.props.ref).toBe(ref)
expect(clone.props.id).toBe('cloned')
})

it('forwards a cloned ref through a function component to the host node', () => {
const { render, container } = setup()
const ref = React.createRef<HTMLSpanElement>()
render(React.cloneElement(<Child />, { ref, id: 'cloned' }))
expect(ref.current).toBe(container.querySelector('#cloned'))
})
})