RFC: warn when returning different hooks on subsequent renders (#14585)

* warn when returning different hooks on next render

like it says. adds a field to Hook to track effect 'type', and compares when cloning subsequently.

* lint

* review changes

- numbered enum for hook types
- s/hookType/_debugType
- better dce

* cleaner detection location

* redundant comments

* different EffectHook / LayoutEffectHook

* prettier

* top level currentHookType

* nulling currentHookType

need to verify dce still works

* small enhancements

* hook order checks for useContext/useImperative

* prettier

* stray whitespace

* move some bits around

* better errors

* pass tests

* lint, flow

* show a before - after diff

* an error stack in the warning

* lose currentHookMatches, fix a test

* tidy

* clear the mismatch only in dev

* pass flow

* side by side diff

* tweak warning

* pass flow

* dedupe warnings per fiber, nits

* better format

* nit

* fix bad merge, pass flow

* lint

* missing hooktype enum

* merge currentHookType/currentHookNameInDev, fix nits

* lint

* final nits
This commit is contained in:
Sunil Pai 2019-01-22 22:40:07 +00:00 committed by GitHub
parent 3fbebb2a0b
commit ecd919a2f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 253 additions and 18 deletions

View File

@ -54,6 +54,27 @@ type UpdateQueue<S, A> = {
eagerState: S | null,
};
type HookType =
| 'useState'
| 'useReducer'
| 'useContext'
| 'useRef'
| 'useEffect'
| 'useLayoutEffect'
| 'useCallback'
| 'useMemo'
| 'useImperativeHandle'
| 'useDebugValue';
// the first instance of a hook mismatch in a component,
// represented by a portion of it's stacktrace
let currentHookMismatch = null;
let didWarnAboutMismatchedHooksForComponent;
if (__DEV__) {
didWarnAboutMismatchedHooksForComponent = new Set();
}
export type Hook = {
memoizedState: any,
@ -64,6 +85,10 @@ export type Hook = {
next: Hook | null,
};
type HookDev = Hook & {
_debugType: HookType,
};
type Effect = {
tag: HookEffectTag,
create: () => mixed,
@ -118,7 +143,7 @@ let numberOfReRenders: number = -1;
const RE_RENDER_LIMIT = 25;
// In DEV, this is the name of the currently executing primitive hook
let currentHookNameInDev: ?string;
let currentHookNameInDev: ?HookType = null;
function resolveCurrentlyRenderingFiber(): Fiber {
invariant(
@ -170,6 +195,95 @@ function areHookInputsEqual(
return true;
}
// till we have String::padEnd, a small function to
// right-pad strings with spaces till a minimum length
function padEndSpaces(string: string, length: number) {
if (__DEV__) {
if (string.length >= length) {
return string;
} else {
return string + ' ' + new Array(length - string.length).join(' ');
}
}
}
function flushHookMismatchWarnings() {
// we'll show the diff of the low level hooks,
// and a stack trace so the dev can locate where
// the first mismatch is coming from
if (__DEV__) {
if (currentHookMismatch !== null) {
let componentName = getComponentName(
((currentlyRenderingFiber: any): Fiber).type,
);
if (!didWarnAboutMismatchedHooksForComponent.has(componentName)) {
didWarnAboutMismatchedHooksForComponent.add(componentName);
const hookStackDiff = [];
let current = firstCurrentHook;
const previousOrder = [];
while (current !== null) {
previousOrder.push(((current: any): HookDev)._debugType);
current = current.next;
}
let workInProgress = firstWorkInProgressHook;
const nextOrder = [];
while (workInProgress !== null) {
nextOrder.push(((workInProgress: any): HookDev)._debugType);
workInProgress = workInProgress.next;
}
// some bookkeeping for formatting the output table
const columnLength = Math.max.apply(
null,
previousOrder
.map(hook => hook.length)
.concat(' Previous render'.length),
);
let hookStackHeader =
((padEndSpaces(' Previous render', columnLength): any): string) +
' Next render\n';
const hookStackWidth = hookStackHeader.length;
hookStackHeader += ' ' + new Array(hookStackWidth - 2).join('-');
const hookStackFooter = ' ' + new Array(hookStackWidth - 2).join('^');
const hookStackLength = Math.max(
previousOrder.length,
nextOrder.length,
);
for (let i = 0; i < hookStackLength; i++) {
hookStackDiff.push(
((padEndSpaces(i + 1 + '. ', 3): any): string) +
((padEndSpaces(previousOrder[i], columnLength): any): string) +
' ' +
nextOrder[i],
);
if (previousOrder[i] !== nextOrder[i]) {
break;
}
}
warning(
false,
'React has detected a change in the order of Hooks called by %s. ' +
'This will lead to bugs and errors if not fixed. ' +
'For more information, read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' +
'%s\n' +
'%s\n' +
'%s\n' +
'The first Hook type mismatch occured at:\n' +
'%s\n\n' +
'This error occurred in the following component:',
componentName,
hookStackHeader,
hookStackDiff.join('\n'),
hookStackFooter,
currentHookMismatch,
);
}
currentHookMismatch = null;
}
}
}
export function renderWithHooks(
current: Fiber | null,
workInProgress: Fiber,
@ -221,6 +335,7 @@ export function renderWithHooks(
getComponentName(Component),
);
}
flushHookMismatchWarnings();
}
} while (didScheduleRenderPhaseUpdate);
@ -248,7 +363,7 @@ export function renderWithHooks(
componentUpdateQueue = null;
if (__DEV__) {
currentHookNameInDev = undefined;
currentHookNameInDev = null;
}
// These were reset above
@ -281,6 +396,9 @@ export function resetHooks(): void {
if (!enableHooks) {
return;
}
if (__DEV__) {
flushHookMismatchWarnings();
}
// This is used to reset the state of this module when a component throws.
// It's also called inside mountIndeterminateComponent if we determine the
@ -297,7 +415,7 @@ export function resetHooks(): void {
componentUpdateQueue = null;
if (__DEV__) {
currentHookNameInDev = undefined;
currentHookNameInDev = null;
}
didScheduleRenderPhaseUpdate = false;
@ -306,27 +424,63 @@ export function resetHooks(): void {
}
function createHook(): Hook {
return {
memoizedState: null,
let hook: Hook = __DEV__
? {
_debugType: ((currentHookNameInDev: any): HookType),
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
next: null,
}
: {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null,
};
return hook;
}
function cloneHook(hook: Hook): Hook {
return {
memoizedState: hook.memoizedState,
let nextHook: Hook = __DEV__
? {
_debugType: ((currentHookNameInDev: any): HookType),
memoizedState: hook.memoizedState,
baseState: hook.baseState,
queue: hook.queue,
baseUpdate: hook.baseUpdate,
baseState: hook.baseState,
queue: hook.queue,
baseUpdate: hook.baseUpdate,
next: null,
};
next: null,
}
: {
memoizedState: hook.memoizedState,
baseState: hook.baseState,
queue: hook.queue,
baseUpdate: hook.baseUpdate,
next: null,
};
if (__DEV__) {
if (!currentHookMismatch) {
if (currentHookNameInDev !== ((hook: any): HookDev)._debugType) {
currentHookMismatch = new Error('tracer').stack
.split('\n')
.slice(4)
.join('\n');
}
}
}
return nextHook;
}
function createWorkInProgressHook(): Hook {
@ -390,6 +544,8 @@ export function useContext<T>(
): T {
if (__DEV__) {
currentHookNameInDev = 'useContext';
createWorkInProgressHook();
currentHookNameInDev = null;
}
// Ensure we're in a function component (class components support only the
// .unstable_read() form)
@ -422,6 +578,7 @@ export function useReducer<S, A>(
}
let fiber = (currentlyRenderingFiber = resolveCurrentlyRenderingFiber());
workInProgressHook = createWorkInProgressHook();
currentHookNameInDev = null;
let queue: UpdateQueue<S, A> | null = (workInProgressHook.queue: any);
if (queue !== null) {
// Already have a queue, so this is an update.
@ -600,7 +757,11 @@ function pushEffect(tag, create, destroy, deps) {
export function useRef<T>(initialValue: T): {current: T} {
currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
if (__DEV__) {
currentHookNameInDev = 'useRef';
}
workInProgressHook = createWorkInProgressHook();
currentHookNameInDev = null;
let ref;
if (workInProgressHook.memoizedState === null) {
@ -620,7 +781,9 @@ export function useLayoutEffect(
deps: Array<mixed> | void | null,
): void {
if (__DEV__) {
currentHookNameInDev = 'useLayoutEffect';
if (currentHookNameInDev !== 'useImperativeHandle') {
currentHookNameInDev = 'useLayoutEffect';
}
}
useEffectImpl(UpdateEffect, UnmountMutation | MountLayout, create, deps);
}
@ -653,6 +816,7 @@ function useEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
const prevDeps = prevEffect.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(NoHookEffect, create, destroy, nextDeps);
currentHookNameInDev = null;
return;
}
}
@ -665,6 +829,7 @@ function useEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
destroy,
nextDeps,
);
currentHookNameInDev = null;
}
export function useImperativeHandle<T>(
@ -746,11 +911,13 @@ export function useCallback<T>(
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
currentHookNameInDev = null;
return prevState[0];
}
}
}
workInProgressHook.memoizedState = [callback, nextDeps];
currentHookNameInDev = null;
return callback;
}
@ -772,6 +939,7 @@ export function useMemo<T>(
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
currentHookNameInDev = null;
return prevState[0];
}
}
@ -782,6 +950,7 @@ export function useMemo<T>(
const nextValue = nextCreate();
currentlyRenderingFiber = fiber;
workInProgressHook.memoizedState = [nextValue, nextDeps];
currentHookNameInDev = null;
return nextValue;
}

View File

@ -699,4 +699,70 @@ describe('ReactHooks', () => {
'Hooks can only be called inside the body of a function component',
);
});
it('warns on using differently ordered hooks on subsequent renders', () => {
const {useState, useReducer} = React;
function useCustomHook() {
return useState(0);
}
function App(props) {
/* eslint-disable no-unused-vars */
if (props.flip) {
useCustomHook(0);
useReducer((s, a) => a, 0);
} else {
useReducer((s, a) => a, 0);
useCustomHook(0);
}
return null;
/* eslint-enable no-unused-vars */
}
let root = ReactTestRenderer.create(<App flip={false} />);
expect(() => {
root.update(<App flip={true} />);
}).toWarnDev([
'Warning: React has detected a change in the order of Hooks called by App. ' +
'This will lead to bugs and errors if not fixed. For more information, ' +
'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' +
' Previous render Next render\n' +
' -------------------------------\n' +
'1. useReducer useState\n' +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^',
]);
// further warnings for this component are silenced
root.update(<App flip={false} />);
});
it('detects a bad hook order even if the component throws', () => {
const {useState, useReducer} = React;
function useCustomHook() {
useState(0);
}
function App(props) {
/* eslint-disable no-unused-vars */
if (props.flip) {
useCustomHook();
useReducer((s, a) => a, 0);
throw new Error('custom error');
} else {
useReducer((s, a) => a, 0);
useCustomHook();
}
return null;
/* eslint-enable no-unused-vars */
}
let root = ReactTestRenderer.create(<App flip={false} />);
expect(() => {
expect(() => root.update(<App flip={true} />)).toThrow('custom error');
}).toWarnDev([
'Warning: React has detected a change in the order of Hooks called by App. ' +
'This will lead to bugs and errors if not fixed. For more information, ' +
'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' +
' Previous render Next render\n' +
' -------------------------------\n' +
'1. useReducer useState\n' +
' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^',
]);
});
});