From 77f002077c09a87a5d7734e7fc096abe0db80b8b Mon Sep 17 00:00:00 2001 From: Valerii Strilets Date: Sat, 12 Sep 2026 01:09:12 +0300 Subject: [PATCH] --- .changeset/ref-as-prop.md | 5 +++ packages/redact/src/react/element.ts | 4 ++- tests/element-ref-prop.test.tsx | 50 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/ref-as-prop.md create mode 100644 tests/element-ref-prop.test.tsx diff --git a/.changeset/ref-as-prop.md b/.changeset/ref-as-prop.md new file mode 100644 index 0000000..490fff4 --- /dev/null +++ b/.changeset/ref-as-prop.md @@ -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. diff --git a/packages/redact/src/react/element.ts b/packages/redact/src/react/element.ts index 878ed99..8977505 100644 --- a/packages/redact/src/react/element.ts +++ b/packages/redact/src/react/element.ts @@ -14,7 +14,8 @@ export const Fragment = REACT_FRAGMENT_TYPE as unknown as (props: { ref?: Ref }) => ReactElement -const RESERVED_PROPS: Record = { 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 = { key: 1, __self: 1, __source: 1 } export function createElement( type: any, @@ -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] } } diff --git a/tests/element-ref-prop.test.tsx b/tests/element-ref-prop.test.tsx new file mode 100644 index 0000000..49c6c59 --- /dev/null +++ b/tests/element-ref-prop.test.tsx @@ -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 }) { + return +} + +describe('ref as a prop', () => { + it('keeps ref in props on createElement', () => { + const ref = React.createRef() + expect(React.createElement(Child, { ref }).props.ref).toBe(ref) + }) + + it('keeps ref in props on cloneElement', () => { + const ref = React.createRef() + const clone = React.cloneElement(, { ref }) + expect(clone.props.ref).toBe(ref) + }) + + it('leaves the original ref alone when cloning without one', () => { + const ref = React.createRef() + const clone = React.cloneElement(, { 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() + render(React.cloneElement(, { ref, id: 'cloned' })) + expect(ref.current).toBe(container.querySelector('#cloned')) + }) +})