Skip to content

Commit ec53821

Browse files
acdlitekassens
andcommitted
Fix: Class components should "consume" ref prop
When a ref is passed to a class component, the class instance is attached to the ref's current property automatically. This different from function components, where you have to do something extra to attach a ref to an instance, like passing the ref to `useImperativeHandle`. Existing class component code is written with the assumption that a ref will not be passed through as a prop. For example, class components that act as indirections often spread `this.props` onto a child component. To maintain this expectation, we should remove the ref from the props object ("consume" it) before passing it to lifecycle methods. Without this change, much existing code will break because the ref will attach to the inner component instead of the outer one. This is not an issue for function components because we used to warn if you passed a ref to a function component. Instead, you had to use `forwardRef`, which also implements this "consuming" behavior. Co-authored-by: Jan Kassens <jan@kassens.net>
1 parent 1becfaf commit ec53821

File tree

5 files changed

+122
-20
lines changed

5 files changed

+122
-20
lines changed

packages/react-dom/src/__tests__/ReactCompositeComponent-test.js

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -268,29 +268,17 @@ describe('ReactCompositeComponent', () => {
268268
await act(() => {
269269
root.render(<Component ref={refFn1} />);
270270
});
271-
if (gate(flags => flags.enableRefAsProp)) {
272-
expect(instance1.props).toEqual({prop: 'testKey', ref: refFn1});
273-
} else {
274-
expect(instance1.props).toEqual({prop: 'testKey'});
275-
}
271+
expect(instance1.props).toEqual({prop: 'testKey'});
276272

277273
await act(() => {
278274
root.render(<Component ref={refFn2} prop={undefined} />);
279275
});
280-
if (gate(flags => flags.enableRefAsProp)) {
281-
expect(instance2.props).toEqual({prop: 'testKey', ref: refFn2});
282-
} else {
283-
expect(instance2.props).toEqual({prop: 'testKey'});
284-
}
276+
expect(instance2.props).toEqual({prop: 'testKey'});
285277

286278
await act(() => {
287279
root.render(<Component ref={refFn3} prop={null} />);
288280
});
289-
if (gate(flags => flags.enableRefAsProp)) {
290-
expect(instance3.props).toEqual({prop: null, ref: refFn3});
291-
} else {
292-
expect(instance3.props).toEqual({prop: null});
293-
}
281+
expect(instance3.props).toEqual({prop: null});
294282
});
295283

296284
it('should not mutate passed-in props object', async () => {

packages/react-reconciler/src/ReactFiberClassComponent.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
enableDebugTracing,
2424
enableSchedulingProfiler,
2525
enableLazyContextPropagation,
26+
enableRefAsProp,
2627
} from 'shared/ReactFeatureFlags';
2728
import ReactStrictModeWarnings from './ReactStrictModeWarnings';
2829
import {isMounted} from './ReactFiberTreeReflection';
@@ -1252,7 +1253,15 @@ export function resolveClassComponentProps(
12521253
}
12531254
}
12541255

1255-
// TODO: Remove ref from props object
1256+
if (enableRefAsProp) {
1257+
// Remove ref from the props object, if it exists.
1258+
if ('ref' in newProps) {
1259+
if (newProps === baseProps) {
1260+
newProps = assign({}, newProps);
1261+
}
1262+
delete newProps.ref;
1263+
}
1264+
}
12561265

12571266
return newProps;
12581267
}

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) {
471471
if (__DEV__) {
472472
if (
473473
!finishedWork.type.defaultProps &&
474+
!('ref' in finishedWork.memoizedProps) &&
474475
!didWarnAboutReassigningProps
475476
) {
476477
if (instance.props !== finishedWork.memoizedProps) {
@@ -807,7 +808,11 @@ function commitClassLayoutLifecycles(
807808
// but instead we rely on them being set during last render.
808809
// TODO: revisit this when we implement resuming.
809810
if (__DEV__) {
810-
if (!finishedWork.type.defaultProps && !didWarnAboutReassigningProps) {
811+
if (
812+
!finishedWork.type.defaultProps &&
813+
!('ref' in finishedWork.memoizedProps) &&
814+
!didWarnAboutReassigningProps
815+
) {
811816
if (instance.props !== finishedWork.memoizedProps) {
812817
console.error(
813818
'Expected %s props to match memoized props before ' +
@@ -856,7 +861,11 @@ function commitClassLayoutLifecycles(
856861
// but instead we rely on them being set during last render.
857862
// TODO: revisit this when we implement resuming.
858863
if (__DEV__) {
859-
if (!finishedWork.type.defaultProps && !didWarnAboutReassigningProps) {
864+
if (
865+
!finishedWork.type.defaultProps &&
866+
!('ref' in finishedWork.memoizedProps) &&
867+
!didWarnAboutReassigningProps
868+
) {
860869
if (instance.props !== finishedWork.memoizedProps) {
861870
console.error(
862871
'Expected %s props to match memoized props before ' +
@@ -913,7 +922,11 @@ function commitClassCallbacks(finishedWork: Fiber) {
913922
if (updateQueue !== null) {
914923
const instance = finishedWork.stateNode;
915924
if (__DEV__) {
916-
if (!finishedWork.type.defaultProps && !didWarnAboutReassigningProps) {
925+
if (
926+
!finishedWork.type.defaultProps &&
927+
!('ref' in finishedWork.memoizedProps) &&
928+
!didWarnAboutReassigningProps
929+
) {
917930
if (instance.props !== finishedWork.memoizedProps) {
918931
console.error(
919932
'Expected %s props to match memoized props before ' +
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @emails react-core
8+
*/
9+
10+
'use strict';
11+
12+
let React;
13+
let ReactNoop;
14+
let Scheduler;
15+
let act;
16+
let assertLog;
17+
18+
describe('ReactClassComponentPropResolution', () => {
19+
beforeEach(() => {
20+
jest.resetModules();
21+
22+
React = require('react');
23+
ReactNoop = require('react-noop-renderer');
24+
Scheduler = require('scheduler');
25+
act = require('internal-test-utils').act;
26+
assertLog = require('internal-test-utils').assertLog;
27+
});
28+
29+
function Text({text}) {
30+
Scheduler.log(text);
31+
return text;
32+
}
33+
34+
test('resolves ref and default props before calling lifecycle methods', async () => {
35+
const root = ReactNoop.createRoot();
36+
37+
function getPropKeys(props) {
38+
return Object.keys(props).join(', ');
39+
}
40+
41+
class Component extends React.Component {
42+
shouldComponentUpdate(props) {
43+
Scheduler.log(
44+
'shouldComponentUpdate (prev props): ' + getPropKeys(this.props),
45+
);
46+
Scheduler.log(
47+
'shouldComponentUpdate (next props): ' + getPropKeys(props),
48+
);
49+
return true;
50+
}
51+
componentDidUpdate(props) {
52+
Scheduler.log('componentDidUpdate (prev props): ' + getPropKeys(props));
53+
Scheduler.log(
54+
'componentDidUpdate (next props): ' + getPropKeys(this.props),
55+
);
56+
return true;
57+
}
58+
componentDidMount() {
59+
Scheduler.log('componentDidMount: ' + getPropKeys(this.props));
60+
return true;
61+
}
62+
render() {
63+
return <Text text={'render: ' + getPropKeys(this.props)} />;
64+
}
65+
}
66+
67+
Component.defaultProps = {
68+
default: 'yo',
69+
};
70+
71+
// `ref` should never appear as a prop. `default` always should.
72+
73+
// Mount
74+
const ref = React.createRef();
75+
await act(async () => {
76+
root.render(<Component text="Yay" ref={ref} />);
77+
});
78+
assertLog(['render: text, default', 'componentDidMount: text, default']);
79+
80+
// Update
81+
await act(async () => {
82+
root.render(<Component text="Yay (again)" ref={ref} />);
83+
});
84+
assertLog([
85+
'shouldComponentUpdate (prev props): text, default',
86+
'shouldComponentUpdate (next props): text, default',
87+
'render: text, default',
88+
'componentDidUpdate (prev props): text, default',
89+
'componentDidUpdate (next props): text, default',
90+
]);
91+
});
92+
});

packages/react/src/__tests__/ReactCreateElement-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('ReactCreateElement', () => {
9090
);
9191
});
9292

93-
// @gate !enableRefAsProp
93+
// @gate !enableRefAsProp || !__DEV__
9494
it('should warn when `ref` is being accessed', async () => {
9595
class Child extends React.Component {
9696
render() {

0 commit comments

Comments
 (0)