Skip to content

Commit dd7f512

Browse files
acdlitekassens
authored andcommitted
Fix: Class components should "consume" ref prop (facebook#28719)
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. There are a few places in the reconciler where we modify the fiber's internal props object before passing it to userspace. The trickiest one is class components, because the props object gets exposed in many different places, including as a property on the class instance. This was already accounted for when we added support for setting default props on a lazy wrapper (i.e. `React.lazy` that resolves to a class component). In all of these same places, we will also need to remove the ref prop when `enableRefAsProp` is on. Closes facebook#28602 --------- Co-authored-by: Jan Kassens <[email protected]>
1 parent 5113f27 commit dd7f512

File tree

8 files changed

+243
-51
lines changed

8 files changed

+243
-51
lines changed

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

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -261,29 +261,17 @@ describe('ReactCompositeComponent', () => {
261261
await act(() => {
262262
root.render(<Component ref={refFn1} />);
263263
});
264-
if (gate(flags => flags.enableRefAsProp)) {
265-
expect(instance1.props).toEqual({prop: 'testKey', ref: refFn1});
266-
} else {
267-
expect(instance1.props).toEqual({prop: 'testKey'});
268-
}
264+
expect(instance1.props).toEqual({prop: 'testKey'});
269265

270266
await act(() => {
271267
root.render(<Component ref={refFn2} prop={undefined} />);
272268
});
273-
if (gate(flags => flags.enableRefAsProp)) {
274-
expect(instance2.props).toEqual({prop: 'testKey', ref: refFn2});
275-
} else {
276-
expect(instance2.props).toEqual({prop: 'testKey'});
277-
}
269+
expect(instance2.props).toEqual({prop: 'testKey'});
278270

279271
await act(() => {
280272
root.render(<Component ref={refFn3} prop={null} />);
281273
});
282-
if (gate(flags => flags.enableRefAsProp)) {
283-
expect(instance3.props).toEqual({prop: null, ref: refFn3});
284-
} else {
285-
expect(instance3.props).toEqual({prop: null});
286-
}
274+
expect(instance3.props).toEqual({prop: null});
287275
});
288276

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

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ import {
245245
mountClassInstance,
246246
resumeMountClassInstance,
247247
updateClassInstance,
248+
resolveClassComponentProps,
248249
} from './ReactFiberClassComponent';
249250
import {resolveDefaultProps} from './ReactFiberLazyComponent';
250251
import {
@@ -1763,9 +1764,9 @@ function mountLazyComponent(
17631764
// Store the unwrapped component in the type.
17641765
workInProgress.type = Component;
17651766

1766-
const resolvedProps = resolveDefaultProps(Component, props);
17671767
if (typeof Component === 'function') {
17681768
if (isFunctionClassComponent(Component)) {
1769+
const resolvedProps = resolveClassComponentProps(Component, props, false);
17691770
workInProgress.tag = ClassComponent;
17701771
if (__DEV__) {
17711772
workInProgress.type = Component =
@@ -1779,6 +1780,7 @@ function mountLazyComponent(
17791780
renderLanes,
17801781
);
17811782
} else {
1783+
const resolvedProps = resolveDefaultProps(Component, props);
17821784
workInProgress.tag = FunctionComponent;
17831785
if (__DEV__) {
17841786
validateFunctionComponentInDev(workInProgress, Component);
@@ -1796,6 +1798,7 @@ function mountLazyComponent(
17961798
} else if (Component !== undefined && Component !== null) {
17971799
const $$typeof = Component.$$typeof;
17981800
if ($$typeof === REACT_FORWARD_REF_TYPE) {
1801+
const resolvedProps = resolveDefaultProps(Component, props);
17991802
workInProgress.tag = ForwardRef;
18001803
if (__DEV__) {
18011804
workInProgress.type = Component =
@@ -1809,6 +1812,7 @@ function mountLazyComponent(
18091812
renderLanes,
18101813
);
18111814
} else if ($$typeof === REACT_MEMO_TYPE) {
1815+
const resolvedProps = resolveDefaultProps(Component, props);
18121816
workInProgress.tag = MemoComponent;
18131817
return updateMemoComponent(
18141818
null,
@@ -3939,10 +3943,11 @@ function beginWork(
39393943
case ClassComponent: {
39403944
const Component = workInProgress.type;
39413945
const unresolvedProps = workInProgress.pendingProps;
3942-
const resolvedProps =
3943-
workInProgress.elementType === Component
3944-
? unresolvedProps
3945-
: resolveDefaultProps(Component, unresolvedProps);
3946+
const resolvedProps = resolveClassComponentProps(
3947+
Component,
3948+
unresolvedProps,
3949+
workInProgress.elementType === Component,
3950+
);
39463951
return updateClassComponent(
39473952
current,
39483953
workInProgress,
@@ -4025,10 +4030,11 @@ function beginWork(
40254030
}
40264031
const Component = workInProgress.type;
40274032
const unresolvedProps = workInProgress.pendingProps;
4028-
const resolvedProps =
4029-
workInProgress.elementType === Component
4030-
? unresolvedProps
4031-
: resolveDefaultProps(Component, unresolvedProps);
4033+
const resolvedProps = resolveClassComponentProps(
4034+
Component,
4035+
unresolvedProps,
4036+
workInProgress.elementType === Component,
4037+
);
40324038
return mountIncompleteClassComponent(
40334039
current,
40344040
workInProgress,
@@ -4043,10 +4049,11 @@ function beginWork(
40434049
}
40444050
const Component = workInProgress.type;
40454051
const unresolvedProps = workInProgress.pendingProps;
4046-
const resolvedProps =
4047-
workInProgress.elementType === Component
4048-
? unresolvedProps
4049-
: resolveDefaultProps(Component, unresolvedProps);
4052+
const resolvedProps = resolveClassComponentProps(
4053+
Component,
4054+
unresolvedProps,
4055+
workInProgress.elementType === Component,
4056+
);
40504057
return mountIncompleteFunctionComponent(
40514058
current,
40524059
workInProgress,

packages/react-reconciler/src/ReactFiberClassComponent.js

Lines changed: 57 additions & 8 deletions
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';
@@ -34,7 +35,6 @@ import assign from 'shared/assign';
3435
import isArray from 'shared/isArray';
3536
import {REACT_CONTEXT_TYPE, REACT_CONSUMER_TYPE} from 'shared/ReactSymbols';
3637

37-
import {resolveDefaultProps} from './ReactFiberLazyComponent';
3838
import {
3939
DebugTracingMode,
4040
NoMode,
@@ -904,7 +904,12 @@ function resumeMountClassInstance(
904904
): boolean {
905905
const instance = workInProgress.stateNode;
906906

907-
const oldProps = workInProgress.memoizedProps;
907+
const unresolvedOldProps = workInProgress.memoizedProps;
908+
const oldProps = resolveClassComponentProps(
909+
ctor,
910+
unresolvedOldProps,
911+
workInProgress.type === workInProgress.elementType,
912+
);
908913
instance.props = oldProps;
909914

910915
const oldContext = instance.context;
@@ -926,6 +931,13 @@ function resumeMountClassInstance(
926931
typeof getDerivedStateFromProps === 'function' ||
927932
typeof instance.getSnapshotBeforeUpdate === 'function';
928933

934+
// When comparing whether props changed, we should compare using the
935+
// unresolved props object that is stored on the fiber, rather than the
936+
// one that gets assigned to the instance, because that object may have been
937+
// cloned to resolve default props and/or remove `ref`.
938+
const unresolvedNewProps = workInProgress.pendingProps;
939+
const didReceiveNewProps = unresolvedNewProps !== unresolvedOldProps;
940+
929941
// Note: During these life-cycles, instance.props/instance.state are what
930942
// ever the previously attempted to render - not the "current". However,
931943
// during componentDidUpdate we pass the "current" props.
@@ -937,7 +949,7 @@ function resumeMountClassInstance(
937949
(typeof instance.UNSAFE_componentWillReceiveProps === 'function' ||
938950
typeof instance.componentWillReceiveProps === 'function')
939951
) {
940-
if (oldProps !== newProps || oldContext !== nextContext) {
952+
if (didReceiveNewProps || oldContext !== nextContext) {
941953
callComponentWillReceiveProps(
942954
workInProgress,
943955
instance,
@@ -955,7 +967,7 @@ function resumeMountClassInstance(
955967
suspendIfUpdateReadFromEntangledAsyncAction();
956968
newState = workInProgress.memoizedState;
957969
if (
958-
oldProps === newProps &&
970+
!didReceiveNewProps &&
959971
oldState === newState &&
960972
!hasContextChanged() &&
961973
!checkHasForceUpdateAfterProcessing()
@@ -1052,10 +1064,11 @@ function updateClassInstance(
10521064
cloneUpdateQueue(current, workInProgress);
10531065

10541066
const unresolvedOldProps = workInProgress.memoizedProps;
1055-
const oldProps =
1056-
workInProgress.type === workInProgress.elementType
1057-
? unresolvedOldProps
1058-
: resolveDefaultProps(workInProgress.type, unresolvedOldProps);
1067+
const oldProps = resolveClassComponentProps(
1068+
ctor,
1069+
unresolvedOldProps,
1070+
workInProgress.type === workInProgress.elementType,
1071+
);
10591072
instance.props = oldProps;
10601073
const unresolvedNewProps = workInProgress.pendingProps;
10611074

@@ -1225,6 +1238,42 @@ function updateClassInstance(
12251238
return shouldUpdate;
12261239
}
12271240

1241+
export function resolveClassComponentProps(
1242+
Component: any,
1243+
baseProps: Object,
1244+
// Only resolve default props if this is a lazy component. Otherwise, they
1245+
// would have already been resolved by the JSX runtime.
1246+
// TODO: We're going to remove default prop resolution from the JSX runtime
1247+
// and keep it only for class components. As part of that change, we should
1248+
// remove this extra check.
1249+
alreadyResolvedDefaultProps: boolean,
1250+
): Object {
1251+
let newProps = baseProps;
1252+
1253+
// Resolve default props. Taken from old JSX runtime, where this used to live.
1254+
const defaultProps = Component.defaultProps;
1255+
if (defaultProps && !alreadyResolvedDefaultProps) {
1256+
newProps = assign({}, newProps, baseProps);
1257+
for (const propName in defaultProps) {
1258+
if (newProps[propName] === undefined) {
1259+
newProps[propName] = defaultProps[propName];
1260+
}
1261+
}
1262+
}
1263+
1264+
if (enableRefAsProp) {
1265+
// Remove ref from the props object, if it exists.
1266+
if ('ref' in newProps) {
1267+
if (newProps === baseProps) {
1268+
newProps = assign({}, newProps);
1269+
}
1270+
delete newProps.ref;
1271+
}
1272+
}
1273+
1274+
return newProps;
1275+
}
1276+
12281277
export {
12291278
constructClassInstance,
12301279
mountClassInstance,

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ import {
104104
setCurrentFiber as setCurrentDebugFiberInDEV,
105105
getCurrentFiber as getCurrentDebugFiberInDEV,
106106
} from './ReactCurrentFiber';
107-
import {resolveDefaultProps} from './ReactFiberLazyComponent';
107+
import {resolveClassComponentProps} from './ReactFiberClassComponent';
108108
import {
109109
isCurrentUpdateNested,
110110
getCommitTime,
@@ -244,7 +244,11 @@ function shouldProfile(current: Fiber): boolean {
244244
}
245245

246246
function callComponentWillUnmountWithTimer(current: Fiber, instance: any) {
247-
instance.props = current.memoizedProps;
247+
instance.props = resolveClassComponentProps(
248+
current.type,
249+
current.memoizedProps,
250+
current.elementType === current.type,
251+
);
248252
instance.state = current.memoizedState;
249253
if (shouldProfile(current)) {
250254
try {
@@ -471,7 +475,8 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) {
471475
// TODO: revisit this when we implement resuming.
472476
if (__DEV__) {
473477
if (
474-
finishedWork.type === finishedWork.elementType &&
478+
!finishedWork.type.defaultProps &&
479+
!('ref' in finishedWork.memoizedProps) &&
475480
!didWarnAboutReassigningProps
476481
) {
477482
if (instance.props !== finishedWork.memoizedProps) {
@@ -497,9 +502,11 @@ function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) {
497502
}
498503
}
499504
const snapshot = instance.getSnapshotBeforeUpdate(
500-
finishedWork.elementType === finishedWork.type
501-
? prevProps
502-
: resolveDefaultProps(finishedWork.type, prevProps),
505+
resolveClassComponentProps(
506+
finishedWork.type,
507+
prevProps,
508+
finishedWork.elementType === finishedWork.type,
509+
),
503510
prevState,
504511
);
505512
if (__DEV__) {
@@ -807,7 +814,8 @@ function commitClassLayoutLifecycles(
807814
// TODO: revisit this when we implement resuming.
808815
if (__DEV__) {
809816
if (
810-
finishedWork.type === finishedWork.elementType &&
817+
!finishedWork.type.defaultProps &&
818+
!('ref' in finishedWork.memoizedProps) &&
811819
!didWarnAboutReassigningProps
812820
) {
813821
if (instance.props !== finishedWork.memoizedProps) {
@@ -848,17 +856,19 @@ function commitClassLayoutLifecycles(
848856
}
849857
}
850858
} else {
851-
const prevProps =
852-
finishedWork.elementType === finishedWork.type
853-
? current.memoizedProps
854-
: resolveDefaultProps(finishedWork.type, current.memoizedProps);
859+
const prevProps = resolveClassComponentProps(
860+
finishedWork.type,
861+
current.memoizedProps,
862+
finishedWork.elementType === finishedWork.type,
863+
);
855864
const prevState = current.memoizedState;
856865
// We could update instance props and state here,
857866
// but instead we rely on them being set during last render.
858867
// TODO: revisit this when we implement resuming.
859868
if (__DEV__) {
860869
if (
861-
finishedWork.type === finishedWork.elementType &&
870+
!finishedWork.type.defaultProps &&
871+
!('ref' in finishedWork.memoizedProps) &&
862872
!didWarnAboutReassigningProps
863873
) {
864874
if (instance.props !== finishedWork.memoizedProps) {
@@ -918,7 +928,8 @@ function commitClassCallbacks(finishedWork: Fiber) {
918928
const instance = finishedWork.stateNode;
919929
if (__DEV__) {
920930
if (
921-
finishedWork.type === finishedWork.elementType &&
931+
!finishedWork.type.defaultProps &&
932+
!('ref' in finishedWork.memoizedProps) &&
922933
!didWarnAboutReassigningProps
923934
) {
924935
if (instance.props !== finishedWork.memoizedProps) {

packages/react-reconciler/src/ReactFiberLazyComponent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
import assign from 'shared/assign';
1111

1212
export function resolveDefaultProps(Component: any, baseProps: Object): Object {
13+
// TODO: Remove support for default props for everything except class
14+
// components, including setting default props on a lazy wrapper around a
15+
// class type.
16+
1317
if (Component && Component.defaultProps) {
1418
// Resolve default props. Taken from ReactElement
1519
const props = assign({}, baseProps);

0 commit comments

Comments
 (0)