Skip to content

Commit dc30644

Browse files
authored
Convert string ref props to callback props (#28398)
When enableRefAsProp is on, we should always use the props as the source of truth for refs. Not a field on the fiber. In the case of string refs, this presents a problem, because string refs are not passed around internally as strings; they are converted to callback refs. The ref used by the reconciler is not the same as the one the user provided. But since this is a deprecated feature anyway, what we can do is clone the props object and replace it with the internal callback ref. Then we can continue to use the props object as the source of truth. This means the internal callback ref will leak into userspace. The receiving component will receive a callback ref even though the parent passed a string. Which is weird, but again, this is a deprecated feature, and we're only leaving it around behind a flag so that Meta can keep using string refs temporarily while they finish migrating their codebase.
1 parent ddd736d commit dc30644

File tree

2 files changed

+169
-103
lines changed

2 files changed

+169
-103
lines changed

packages/react-reconciler/src/ReactChildFiber.js

Lines changed: 141 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
Fragment,
4242
} from './ReactWorkTags';
4343
import isArray from 'shared/isArray';
44+
import assign from 'shared/assign';
4445
import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
4546
import {enableRefAsProp} from 'shared/ReactFeatureFlags';
4647

@@ -149,128 +150,165 @@ function unwrapThenable<T>(thenable: Thenable<T>): T {
149150
return trackUsedThenable(thenableState, thenable, index);
150151
}
151152

153+
type CoercedStringRef = ((handle: mixed) => void) & {_stringRef: ?string, ...};
154+
155+
function convertStringRefToCallbackRef(
156+
returnFiber: Fiber,
157+
current: Fiber | null,
158+
element: ReactElement,
159+
mixedRef: any,
160+
): CoercedStringRef {
161+
const owner: ?Fiber = (element._owner: any);
162+
if (!owner) {
163+
if (typeof mixedRef !== 'string') {
164+
throw new Error(
165+
'Expected ref to be a function, a string, an object returned by React.createRef(), or null.',
166+
);
167+
}
168+
throw new Error(
169+
`Element ref was specified as a string (${mixedRef}) but no owner was set. This could happen for one of` +
170+
' the following reasons:\n' +
171+
'1. You may be adding a ref to a function component\n' +
172+
"2. You may be adding a ref to a component that was not created inside a component's render method\n" +
173+
'3. You have multiple copies of React loaded\n' +
174+
'See https://reactjs.org/link/refs-must-have-owner for more information.',
175+
);
176+
}
177+
if (owner.tag !== ClassComponent) {
178+
throw new Error(
179+
'Function components cannot have string refs. ' +
180+
'We recommend using useRef() instead. ' +
181+
'Learn more about using refs safely here: ' +
182+
'https://reactjs.org/link/strict-mode-string-ref',
183+
);
184+
}
185+
186+
// At this point, we know the ref isn't an object or function but it could
187+
// be a number. Coerce it to a string.
188+
if (__DEV__) {
189+
checkPropStringCoercion(mixedRef, 'ref');
190+
}
191+
const stringRef = '' + mixedRef;
192+
193+
if (__DEV__) {
194+
if (
195+
// Will already warn with "Function components cannot be given refs"
196+
!(typeof element.type === 'function' && !isReactClass(element.type))
197+
) {
198+
const componentName =
199+
getComponentNameFromFiber(returnFiber) || 'Component';
200+
if (!didWarnAboutStringRefs[componentName]) {
201+
console.error(
202+
'Component "%s" contains the string ref "%s". Support for string refs ' +
203+
'will be removed in a future major release. We recommend using ' +
204+
'useRef() or createRef() instead. ' +
205+
'Learn more about using refs safely here: ' +
206+
'https://reactjs.org/link/strict-mode-string-ref',
207+
componentName,
208+
stringRef,
209+
);
210+
didWarnAboutStringRefs[componentName] = true;
211+
}
212+
}
213+
}
214+
215+
const inst = owner.stateNode;
216+
if (!inst) {
217+
throw new Error(
218+
`Missing owner for string ref ${stringRef}. This error is likely caused by a ` +
219+
'bug in React. Please file an issue.',
220+
);
221+
}
222+
223+
// Check if previous string ref matches new string ref
224+
if (
225+
current !== null &&
226+
current.ref !== null &&
227+
typeof current.ref === 'function' &&
228+
current.ref._stringRef === stringRef
229+
) {
230+
// Reuse the existing string ref
231+
const currentRef: CoercedStringRef = ((current.ref: any): CoercedStringRef);
232+
return currentRef;
233+
}
234+
235+
// Create a new string ref
236+
const ref = function (value: mixed) {
237+
const refs = inst.refs;
238+
if (value === null) {
239+
delete refs[stringRef];
240+
} else {
241+
refs[stringRef] = value;
242+
}
243+
};
244+
ref._stringRef = stringRef;
245+
return ref;
246+
}
247+
152248
function coerceRef(
153249
returnFiber: Fiber,
154250
current: Fiber | null,
251+
workInProgress: Fiber,
155252
element: ReactElement,
156-
) {
253+
): void {
157254
let mixedRef;
158255
if (enableRefAsProp) {
159256
// TODO: This is a temporary, intermediate step. When enableRefAsProp is on,
160257
// we should resolve the `ref` prop during the begin phase of the component
161258
// it's attached to (HostComponent, ClassComponent, etc).
162-
163259
const refProp = element.props.ref;
164260
mixedRef = refProp !== undefined ? refProp : null;
165261
} else {
166262
// Old behavior.
167263
mixedRef = element.ref;
168264
}
169265

266+
let coercedRef;
170267
if (
171268
mixedRef !== null &&
172269
typeof mixedRef !== 'function' &&
173270
typeof mixedRef !== 'object'
174271
) {
175-
if (__DEV__) {
176-
if (
177-
// Will already throw with "Function components cannot have string refs"
178-
!(
179-
element._owner &&
180-
((element._owner: any): Fiber).tag !== ClassComponent
181-
) &&
182-
// Will already warn with "Function components cannot be given refs"
183-
!(typeof element.type === 'function' && !isReactClass(element.type)) &&
184-
// Will already throw with "Element ref was specified as a string (someStringRef) but no owner was set"
185-
element._owner
186-
) {
187-
const componentName =
188-
getComponentNameFromFiber(returnFiber) || 'Component';
189-
if (!didWarnAboutStringRefs[componentName]) {
190-
console.error(
191-
'Component "%s" contains the string ref "%s". Support for string refs ' +
192-
'will be removed in a future major release. We recommend using ' +
193-
'useRef() or createRef() instead. ' +
194-
'Learn more about using refs safely here: ' +
195-
'https://reactjs.org/link/strict-mode-string-ref',
196-
componentName,
197-
mixedRef,
198-
);
199-
didWarnAboutStringRefs[componentName] = true;
200-
}
201-
}
202-
}
203-
204-
if (element._owner) {
205-
const owner: ?Fiber = (element._owner: any);
206-
let inst;
207-
if (owner) {
208-
const ownerFiber = ((owner: any): Fiber);
209-
210-
if (ownerFiber.tag !== ClassComponent) {
211-
throw new Error(
212-
'Function components cannot have string refs. ' +
213-
'We recommend using useRef() instead. ' +
214-
'Learn more about using refs safely here: ' +
215-
'https://reactjs.org/link/strict-mode-string-ref',
216-
);
217-
}
218-
219-
inst = ownerFiber.stateNode;
220-
}
221-
222-
if (!inst) {
223-
throw new Error(
224-
`Missing owner for string ref ${mixedRef}. This error is likely caused by a ` +
225-
'bug in React. Please file an issue.',
226-
);
227-
}
228-
// Assigning this to a const so Flow knows it won't change in the closure
229-
const resolvedInst = inst;
230-
231-
if (__DEV__) {
232-
checkPropStringCoercion(mixedRef, 'ref');
233-
}
234-
const stringRef = '' + mixedRef;
235-
// Check if previous string ref matches new string ref
236-
if (
237-
current !== null &&
238-
current.ref !== null &&
239-
typeof current.ref === 'function' &&
240-
current.ref._stringRef === stringRef
241-
) {
242-
return current.ref;
243-
}
244-
const ref = function (value: mixed) {
245-
const refs = resolvedInst.refs;
246-
if (value === null) {
247-
delete refs[stringRef];
248-
} else {
249-
refs[stringRef] = value;
250-
}
251-
};
252-
ref._stringRef = stringRef;
253-
return ref;
254-
} else {
255-
if (typeof mixedRef !== 'string') {
256-
throw new Error(
257-
'Expected ref to be a function, a string, an object returned by React.createRef(), or null.',
258-
);
259-
}
272+
// Assume this is a string ref. If it's not, then this will throw an error
273+
// to the user.
274+
coercedRef = convertStringRefToCallbackRef(
275+
returnFiber,
276+
current,
277+
element,
278+
mixedRef,
279+
);
260280

261-
if (!element._owner) {
262-
throw new Error(
263-
`Element ref was specified as a string (${mixedRef}) but no owner was set. This could happen for one of` +
264-
' the following reasons:\n' +
265-
'1. You may be adding a ref to a function component\n' +
266-
"2. You may be adding a ref to a component that was not created inside a component's render method\n" +
267-
'3. You have multiple copies of React loaded\n' +
268-
'See https://reactjs.org/link/refs-must-have-owner for more information.',
269-
);
270-
}
281+
if (enableRefAsProp) {
282+
// When enableRefAsProp is on, we should always use the props as the
283+
// source of truth for refs. Not a field on the fiber.
284+
//
285+
// In the case of string refs, this presents a problem, because string
286+
// refs are not passed around internally as strings; they are converted to
287+
// callback refs. The ref used by the reconciler is not the same as the
288+
// one the user provided.
289+
//
290+
// But since this is a deprecated feature anyway, what we can do is clone
291+
// the props object and replace it with the internal callback ref. Then we
292+
// can continue to use the props object as the source of truth.
293+
//
294+
// This means the internal callback ref will leak into userspace. The
295+
// receiving component will receive a callback ref even though the parent
296+
// passed a string. Which is weird, but again, this is a deprecated
297+
// feature, and we're only leaving it around behind a flag so that Meta
298+
// can keep using string refs temporarily while they finish migrating
299+
// their codebase.
300+
const userProvidedProps = workInProgress.pendingProps;
301+
const propsWithInternalCallbackRef = assign({}, userProvidedProps);
302+
propsWithInternalCallbackRef.ref = coercedRef;
303+
workInProgress.pendingProps = propsWithInternalCallbackRef;
271304
}
305+
} else {
306+
coercedRef = mixedRef;
272307
}
273-
return mixedRef;
308+
309+
// TODO: If enableRefAsProp is on, we shouldn't use the `ref` field. We
310+
// should always read the ref from the prop.
311+
workInProgress.ref = coercedRef;
274312
}
275313

276314
function throwOnInvalidObjectType(returnFiber: Fiber, newChild: Object) {
@@ -537,7 +575,7 @@ function createChildReconciler(
537575
) {
538576
// Move based on index
539577
const existing = useFiber(current, element.props);
540-
existing.ref = coerceRef(returnFiber, current, element);
578+
coerceRef(returnFiber, current, existing, element);
541579
existing.return = returnFiber;
542580
if (__DEV__) {
543581
existing._debugOwner = element._owner;
@@ -548,7 +586,7 @@ function createChildReconciler(
548586
}
549587
// Insert
550588
const created = createFiberFromElement(element, returnFiber.mode, lanes);
551-
created.ref = coerceRef(returnFiber, current, element);
589+
coerceRef(returnFiber, current, created, element);
552590
created.return = returnFiber;
553591
if (__DEV__) {
554592
created._debugInfo = debugInfo;
@@ -652,7 +690,7 @@ function createChildReconciler(
652690
returnFiber.mode,
653691
lanes,
654692
);
655-
created.ref = coerceRef(returnFiber, null, newChild);
693+
coerceRef(returnFiber, null, created, newChild);
656694
created.return = returnFiber;
657695
if (__DEV__) {
658696
created._debugInfo = mergeDebugInfo(debugInfo, newChild._debugInfo);
@@ -1481,7 +1519,7 @@ function createChildReconciler(
14811519
) {
14821520
deleteRemainingChildren(returnFiber, child.sibling);
14831521
const existing = useFiber(child, element.props);
1484-
existing.ref = coerceRef(returnFiber, child, element);
1522+
coerceRef(returnFiber, child, existing, element);
14851523
existing.return = returnFiber;
14861524
if (__DEV__) {
14871525
existing._debugOwner = element._owner;
@@ -1513,7 +1551,7 @@ function createChildReconciler(
15131551
return created;
15141552
} else {
15151553
const created = createFiberFromElement(element, returnFiber.mode, lanes);
1516-
created.ref = coerceRef(returnFiber, currentFirstChild, element);
1554+
coerceRef(returnFiber, currentFirstChild, created, element);
15171555
created.return = returnFiber;
15181556
if (__DEV__) {
15191557
created._debugInfo = debugInfo;

packages/react-reconciler/src/__tests__/ReactFiberRefs-test.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,32 @@ describe('ReactFiberRefs', () => {
8484
expect(ref1.current).toBe(null);
8585
expect(ref2.current).not.toBe(null);
8686
});
87+
88+
// @gate enableRefAsProp
89+
test('string ref props are converted to function refs', async () => {
90+
let refProp;
91+
function Child({ref}) {
92+
refProp = ref;
93+
return <div ref={ref} />;
94+
}
95+
96+
let owner;
97+
class Owner extends React.Component {
98+
render() {
99+
owner = this;
100+
return <Child ref="child" />;
101+
}
102+
}
103+
104+
const root = ReactNoop.createRoot();
105+
await act(() => root.render(<Owner />));
106+
107+
// When string refs aren't disabled, and enableRefAsProp is on, string refs
108+
// the receiving component receives a callback ref, not the original string.
109+
// This behavior should never be shipped to open source; it's only here to
110+
// allow Meta to keep using string refs temporarily while they finish
111+
// migrating their codebase.
112+
expect(typeof refProp === 'function').toBe(true);
113+
expect(owner.refs.child.type).toBe('div');
114+
});
87115
});

0 commit comments

Comments
 (0)