Skip to content

Commit 32e5c1b

Browse files
author
Brian Vaughn
committed
useRef: Warn about reading or writing mutable values during render
Reading or writing a ref value during render is only safe if you are implementing the lazy initialization pattern. Other types of reading are unsafe as the ref is a mutable source. Other types of writing are unsafe as they are effectively side effects.
1 parent 51a3aa6 commit 32e5c1b

15 files changed

+564
-121
lines changed

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

+8-10
Original file line numberDiff line numberDiff line change
@@ -474,31 +474,29 @@ describe('ReactDOMServerHooks', () => {
474474
describe('useRef', () => {
475475
itRenders('basic render', async render => {
476476
function Counter(props) {
477-
const count = useRef(0);
478-
return <span>Count: {count.current}</span>;
477+
const ref = useRef();
478+
return <span ref={ref}>Hi</span>;
479479
}
480480

481481
const domNode = await render(<Counter />);
482-
expect(domNode.textContent).toEqual('Count: 0');
482+
expect(domNode.textContent).toEqual('Hi');
483483
});
484484

485485
itRenders(
486486
'multiple times when updates happen during the render phase',
487487
async render => {
488488
function Counter(props) {
489489
const [count, setCount] = useState(0);
490-
const ref = useRef(count);
490+
const ref = useRef();
491491

492492
if (count < 3) {
493493
const newCount = count + 1;
494-
495-
ref.current = newCount;
496494
setCount(newCount);
497495
}
498496

499497
yieldValue(count);
500498

501-
return <span>Count: {ref.current}</span>;
499+
return <span ref={ref}>Count: {count}</span>;
502500
}
503501

504502
const domNode = await render(<Counter />);
@@ -513,7 +511,7 @@ describe('ReactDOMServerHooks', () => {
513511
let firstRef = null;
514512
function Counter(props) {
515513
const [count, setCount] = useState(0);
516-
const ref = useRef(count);
514+
const ref = useRef();
517515
if (firstRef === null) {
518516
firstRef = ref;
519517
} else if (firstRef !== ref) {
@@ -528,12 +526,12 @@ describe('ReactDOMServerHooks', () => {
528526

529527
yieldValue(count);
530528

531-
return <span>Count: {ref.current}</span>;
529+
return <span ref={ref}>Count: {count}</span>;
532530
}
533531

534532
const domNode = await render(<Counter />);
535533
expect(clearYields()).toEqual([0, 1, 2, 3]);
536-
expect(domNode.textContent).toEqual('Count: 0');
534+
expect(domNode.textContent).toEqual('Count: 3');
537535
},
538536
);
539537
});

packages/react-reconciler/src/ReactFiberHooks.new.js

+91-12
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
enableNewReconciler,
2828
decoupleUpdatePriorityFromScheduler,
2929
enableDoubleInvokingEffects,
30+
enableUseRefMutationWarning,
3031
} from 'shared/ReactFeatureFlags';
3132

3233
import {
@@ -1197,14 +1198,92 @@ function pushEffect(tag, create, destroy, deps) {
11971198
return effect;
11981199
}
11991200

1201+
let stackContainsErrorMessage: boolean | null = null;
1202+
1203+
function getCallerStackFrame(): string {
1204+
const stackFrames = new Error('Error message').stack.split('\n');
1205+
1206+
// Some browsers (e.g. Chrome) include the error message in the stack
1207+
// but others (e.g. Firefox) do not.
1208+
if (stackContainsErrorMessage === null) {
1209+
stackContainsErrorMessage = stackFrames[0].includes('Error message');
1210+
}
1211+
1212+
return stackContainsErrorMessage
1213+
? stackFrames.slice(3, 4).join('\n')
1214+
: stackFrames.slice(2, 3).join('\n');
1215+
}
1216+
12001217
function mountRef<T>(initialValue: T): {|current: T|} {
12011218
const hook = mountWorkInProgressHook();
1202-
const ref = {current: initialValue};
1203-
if (__DEV__) {
1204-
Object.seal(ref);
1219+
if (enableUseRefMutationWarning) {
1220+
if (__DEV__) {
1221+
// Support lazy initialization pattern shown in docs.
1222+
// We need to store the caller stack frame so that we don't warn on subsequent renders.
1223+
let hasBeenInitialized = initialValue != null;
1224+
let lazyInitGetterStack = null;
1225+
let didCheckForLazyInit = false;
1226+
1227+
// Only warn once per component+hook.
1228+
let didWarnAboutRead = false;
1229+
let didWarnAboutWrite = false;
1230+
1231+
let current = initialValue;
1232+
const ref = {
1233+
get current() {
1234+
if (!hasBeenInitialized) {
1235+
didCheckForLazyInit = true;
1236+
lazyInitGetterStack = getCallerStackFrame();
1237+
} else if (currentlyRenderingFiber !== null && !didWarnAboutRead) {
1238+
if (
1239+
lazyInitGetterStack === null ||
1240+
lazyInitGetterStack !== getCallerStackFrame()
1241+
) {
1242+
didWarnAboutRead = true;
1243+
console.warn(
1244+
'%s: Unsafe read of a mutable value during render.\n\n' +
1245+
'Reading from a ref during render is only safe if:\n' +
1246+
'1. The ref value has not been updated, or\n' +
1247+
'2. The ref holds a lazily-initialized value that is only set once.\n',
1248+
getComponentName(currentlyRenderingFiber.type) || 'Unknown',
1249+
);
1250+
}
1251+
}
1252+
return current;
1253+
},
1254+
set current(value) {
1255+
if (currentlyRenderingFiber !== null && !didWarnAboutWrite) {
1256+
if (
1257+
hasBeenInitialized ||
1258+
(!hasBeenInitialized && !didCheckForLazyInit)
1259+
) {
1260+
didWarnAboutWrite = true;
1261+
console.warn(
1262+
'%s: Unsafe write of a mutable value during render.\n\n' +
1263+
'Writing to a ref during render is only safe if the ref holds ' +
1264+
'a lazily-initialized value that is only set once.\n',
1265+
getComponentName(currentlyRenderingFiber.type) || 'Unknown',
1266+
);
1267+
}
1268+
}
1269+
1270+
hasBeenInitialized = true;
1271+
current = value;
1272+
},
1273+
};
1274+
Object.seal(ref);
1275+
hook.memoizedState = ref;
1276+
return ref;
1277+
} else {
1278+
const ref = {current: initialValue};
1279+
hook.memoizedState = ref;
1280+
return ref;
1281+
}
1282+
} else {
1283+
const ref = {current: initialValue};
1284+
hook.memoizedState = ref;
1285+
return ref;
12051286
}
1206-
hook.memoizedState = ref;
1207-
return ref;
12081287
}
12091288

12101289
function updateRef<T>(initialValue: T): {|current: T|} {
@@ -1591,24 +1670,24 @@ function startTransition(setPending, callback) {
15911670

15921671
function mountTransition(): [(() => void) => void, boolean] {
15931672
const [isPending, setPending] = mountState(false);
1594-
// The `start` method can be stored on a ref, since `setPending`
1595-
// never changes.
1673+
// The `start` method never changes.
15961674
const start = startTransition.bind(null, setPending);
1597-
mountRef(start);
1675+
const hook = mountWorkInProgressHook();
1676+
hook.memoizedState = start;
15981677
return [start, isPending];
15991678
}
16001679

16011680
function updateTransition(): [(() => void) => void, boolean] {
16021681
const [isPending] = updateState(false);
1603-
const startRef = updateRef();
1604-
const start: (() => void) => void = (startRef.current: any);
1682+
const hook = updateWorkInProgressHook();
1683+
const start = hook.memoizedState;
16051684
return [start, isPending];
16061685
}
16071686

16081687
function rerenderTransition(): [(() => void) => void, boolean] {
16091688
const [isPending] = rerenderState(false);
1610-
const startRef = updateRef();
1611-
const start: (() => void) => void = (startRef.current: any);
1689+
const hook = updateWorkInProgressHook();
1690+
const start = hook.memoizedState;
16121691
return [start, isPending];
16131692
}
16141693

packages/react-reconciler/src/ReactFiberHooks.old.js

+91-12
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
enableSchedulingProfiler,
2727
enableNewReconciler,
2828
decoupleUpdatePriorityFromScheduler,
29+
enableUseRefMutationWarning,
2930
} from 'shared/ReactFeatureFlags';
3031

3132
import {NoMode, BlockingMode, DebugTracingMode} from './ReactTypeOfMode';
@@ -1175,14 +1176,92 @@ function pushEffect(tag, create, destroy, deps) {
11751176
return effect;
11761177
}
11771178

1179+
let stackContainsErrorMessage: boolean | null = null;
1180+
1181+
function getCallerStackFrame(): string {
1182+
const stackFrames = new Error('Error message').stack.split('\n');
1183+
1184+
// Some browsers (e.g. Chrome) include the error message in the stack
1185+
// but others (e.g. Firefox) do not.
1186+
if (stackContainsErrorMessage === null) {
1187+
stackContainsErrorMessage = stackFrames[0].includes('Error message');
1188+
}
1189+
1190+
return stackContainsErrorMessage
1191+
? stackFrames.slice(3, 4).join('\n')
1192+
: stackFrames.slice(2, 3).join('\n');
1193+
}
1194+
11781195
function mountRef<T>(initialValue: T): {|current: T|} {
11791196
const hook = mountWorkInProgressHook();
1180-
const ref = {current: initialValue};
1181-
if (__DEV__) {
1182-
Object.seal(ref);
1197+
if (enableUseRefMutationWarning) {
1198+
if (__DEV__) {
1199+
// Support lazy initialization pattern shown in docs.
1200+
// We need to store the caller stack frame so that we don't warn on subsequent renders.
1201+
let hasBeenInitialized = initialValue != null;
1202+
let lazyInitGetterStack = null;
1203+
let didCheckForLazyInit = false;
1204+
1205+
// Only warn once per component+hook.
1206+
let didWarnAboutRead = false;
1207+
let didWarnAboutWrite = false;
1208+
1209+
let current = initialValue;
1210+
const ref = {
1211+
get current() {
1212+
if (!hasBeenInitialized) {
1213+
didCheckForLazyInit = true;
1214+
lazyInitGetterStack = getCallerStackFrame();
1215+
} else if (currentlyRenderingFiber !== null && !didWarnAboutRead) {
1216+
if (
1217+
lazyInitGetterStack === null ||
1218+
lazyInitGetterStack !== getCallerStackFrame()
1219+
) {
1220+
didWarnAboutRead = true;
1221+
console.warn(
1222+
'%s: Unsafe read of a mutable value during render.\n\n' +
1223+
'Reading from a ref during render is only safe if:\n' +
1224+
'1. The ref value has not been updated, or\n' +
1225+
'2. The ref holds a lazily-initialized value that is only set once.\n',
1226+
getComponentName(currentlyRenderingFiber.type) || 'Unknown',
1227+
);
1228+
}
1229+
}
1230+
return current;
1231+
},
1232+
set current(value) {
1233+
if (currentlyRenderingFiber !== null && !didWarnAboutWrite) {
1234+
if (
1235+
hasBeenInitialized ||
1236+
(!hasBeenInitialized && !didCheckForLazyInit)
1237+
) {
1238+
didWarnAboutWrite = true;
1239+
console.warn(
1240+
'%s: Unsafe write of a mutable value during render.\n\n' +
1241+
'Writing to a ref during render is only safe if the ref holds ' +
1242+
'a lazily-initialized value that is only set once.\n',
1243+
getComponentName(currentlyRenderingFiber.type) || 'Unknown',
1244+
);
1245+
}
1246+
}
1247+
1248+
hasBeenInitialized = true;
1249+
current = value;
1250+
},
1251+
};
1252+
Object.seal(ref);
1253+
hook.memoizedState = ref;
1254+
return ref;
1255+
} else {
1256+
const ref = {current: initialValue};
1257+
hook.memoizedState = ref;
1258+
return ref;
1259+
}
1260+
} else {
1261+
const ref = {current: initialValue};
1262+
hook.memoizedState = ref;
1263+
return ref;
11831264
}
1184-
hook.memoizedState = ref;
1185-
return ref;
11861265
}
11871266

11881267
function updateRef<T>(initialValue: T): {|current: T|} {
@@ -1534,24 +1613,24 @@ function startTransition(setPending, callback) {
15341613

15351614
function mountTransition(): [(() => void) => void, boolean] {
15361615
const [isPending, setPending] = mountState(false);
1537-
// The `start` method can be stored on a ref, since `setPending`
1538-
// never changes.
1616+
// The `start` method never changes.
15391617
const start = startTransition.bind(null, setPending);
1540-
mountRef(start);
1618+
const hook = mountWorkInProgressHook();
1619+
hook.memoizedState = start;
15411620
return [start, isPending];
15421621
}
15431622

15441623
function updateTransition(): [(() => void) => void, boolean] {
15451624
const [isPending] = updateState(false);
1546-
const startRef = updateRef();
1547-
const start: (() => void) => void = (startRef.current: any);
1625+
const hook = updateWorkInProgressHook();
1626+
const start = hook.memoizedState;
15481627
return [start, isPending];
15491628
}
15501629

15511630
function rerenderTransition(): [(() => void) => void, boolean] {
15521631
const [isPending] = rerenderState(false);
1553-
const startRef = updateRef();
1554-
const start: (() => void) => void = (startRef.current: any);
1632+
const hook = updateWorkInProgressHook();
1633+
const start = hook.memoizedState;
15551634
return [start, isPending];
15561635
}
15571636

0 commit comments

Comments
 (0)