diff --git a/packages/react-dom/src/__tests__/ReactComponent-test.js b/packages/react-dom/src/__tests__/ReactComponent-test.js
index 42dae06370edd..678a7ce4f6db9 100644
--- a/packages/react-dom/src/__tests__/ReactComponent-test.js
+++ b/packages/react-dom/src/__tests__/ReactComponent-test.js
@@ -129,6 +129,55 @@ describe('ReactComponent', () => {
}
});
+ // @gate !disableStringRefs
+ it('string refs do not detach and reattach on every render', async () => {
+ spyOnDev(console, 'error').mockImplementation(() => {});
+
+ let refVal;
+ class Child extends React.Component {
+ componentDidUpdate() {
+ // The parent ref should still be attached because it hasn't changed
+ // since the last render. If the ref had changed, then this would be
+ // undefined because refs are attached during the same phase (layout)
+ // as componentDidUpdate, in child -> parent order. So the new parent
+ // ref wouldn't have attached yet.
+ refVal = this.props.contextRef();
+ }
+
+ render() {
+ if (this.props.show) {
+ return
child
;
+ }
+ }
+ }
+
+ class Parent extends React.Component {
+ render() {
+ return (
+
+ this.refs.root}
+ show={this.props.showChild}
+ />
+
+ );
+ }
+ }
+
+ const container = document.createElement('div');
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ root.render();
+ });
+
+ expect(refVal).toBe(undefined);
+ await act(() => {
+ root.render();
+ });
+ expect(refVal).toBe(container.querySelector('#test-root'));
+ });
+
// @gate !disableStringRefs
it('should support string refs on owned components', async () => {
const innerObj = {};
diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js
index 21964cc456e3e..034c853976aa8 100644
--- a/packages/react-reconciler/src/ReactFiberBeginWork.js
+++ b/packages/react-reconciler/src/ReactFiberBeginWork.js
@@ -1048,6 +1048,25 @@ function markRef(current: Fiber | null, workInProgress: Fiber) {
);
}
if (current === null || current.ref !== ref) {
+ if (!disableStringRefs && current !== null) {
+ const oldRef = current.ref;
+ const newRef = ref;
+ if (
+ typeof oldRef === 'function' &&
+ typeof newRef === 'function' &&
+ typeof oldRef.__stringRef === 'string' &&
+ oldRef.__stringRef === newRef.__stringRef &&
+ oldRef.__stringRefType === newRef.__stringRefType &&
+ oldRef.__stringRefOwner === newRef.__stringRefOwner
+ ) {
+ // Although this is a different callback, it represents the same
+ // string ref. To avoid breaking old Meta code that relies on string
+ // refs only being attached once, reuse the old ref. This will
+ // prevent us from detaching and reattaching the ref on each update.
+ workInProgress.ref = oldRef;
+ return;
+ }
+ }
// Schedule a Ref effect
workInProgress.flags |= Ref | RefStatic;
}
diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js
index 2b9955fcc536a..48feb83cdde35 100644
--- a/packages/react/src/jsx/ReactJSXElement.js
+++ b/packages/react/src/jsx/ReactJSXElement.js
@@ -1189,7 +1189,15 @@ function coerceStringRef(mixedRef, owner, type) {
}
}
- return stringRefAsCallbackRef.bind(null, stringRef, type, owner);
+ const callback = stringRefAsCallbackRef.bind(null, stringRef, type, owner);
+ // This is used to check whether two callback refs conceptually represent
+ // the same string ref, and can therefore be reused by the reconciler. Needed
+ // for backwards compatibility with old Meta code that relies on string refs
+ // not being reattached on every render.
+ callback.__stringRef = stringRef;
+ callback.__type = type;
+ callback.__owner = owner;
+ return callback;
}
function stringRefAsCallbackRef(stringRef, type, owner, value) {