Throw an error when using hooks inside useMemo/useState/useReducer, or .memo's comparator (#14608)
* hooks inside useMemo/.memo - failing tests * throw an error when using hooks inside useMemo * throw when using hooks inside .memo's compare fn * faster/better/stronger * same logic for useReducer, tests for the server, etc * Update ReactDOMServerIntegrationHooks-test.internal.js ack lint * nits * whitespace * whitespace * stray semi * Tweak comment * stray unmatched fiber reset * nit
Sunil Pai committed
Jan 18, 2019 at 02:15 UTC
e1cd83e49d24bd4761fb18bb0789715a2d5090f7
4 files changed
+148
-5
packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js
+45
@@ -419,6 +419,51 @@ describe('ReactDOMServerHooks', () => {
419
expect(domNode.textContent).toEqual('HELLO, WORLD.');
420
},
421
);
422
+
423
+ itThrowsWhenRendering(
424
+ 'a hook inside useMemo',
425
+ async render => {
426
+ function App() {
427
+ useMemo(() => {
428
+ useState();
429
+ return 0;
430
+ });
431
+ return null;
432
+ }
433
+ return render(<App />);
434
+ },
435
+ 'Hooks can only be called inside the body of a function component.',
436
+ );
437
+
438
+ itThrowsWhenRendering(
439
+ 'a hook inside useReducer',
440
+ async render => {
441
+ function App() {
442
+ const [value, dispatch] = useReducer((state, action) => {
443
+ useRef(0);
444
+ return state;
445
+ }, 0);
446
+ dispatch('foo');
447
+ return value;
448
+ }
449
+ return render(<App />);
450
+ },
451
+ 'Hooks can only be called inside the body of a function component.',
452
+ );
453
+
454
+ itThrowsWhenRendering(
455
+ 'a hook inside useState',
456
+ async render => {
457
+ function App() {
458
+ useState(() => {
459
+ useRef(0);
460
+ return 0;
461
+ });
462
+ }
463
+ return render(<App />);
464
+ },
465
+ 'Hooks can only be called inside the body of a function component.',
466
+ );
467
});
468
469
describe('useRef', () => {
packages/react-dom/src/server/ReactPartialRendererHooks.js
+10
-2
@@ -234,7 +234,7 @@ export function useReducer<S, A>(
234
currentHookNameInDev = 'useReducer';
235
}
236
}
237
- currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
237
+ let component = (currentlyRenderingComponent = resolveCurrentlyRenderingComponent());
238
workInProgressHook = createWorkInProgressHook();
239
if (isReRender) {
240
// This is a re-render. Apply the new render phase updates to the previous
@@ -253,7 +253,10 @@ export function useReducer<S, A>(
253
// priority because it will always be the same as the current
254
// render's.
255
const action = update.action;
256
+ // Temporarily clear to forbid calling Hooks.
257
+ currentlyRenderingComponent = null;
258
newState = reducer(newState, action);
259
+ currentlyRenderingComponent = component;
260
update = update.next;
261
} while (update !== null);
262
@@ -264,6 +267,7 @@ export function useReducer<S, A>(
267
}
268
return [workInProgressHook.memoizedState, dispatch];
269
} else {
270
+ currentlyRenderingComponent = null;
271
if (reducer === basicStateReducer) {
272
// Special case for `useState`.
273
if (typeof initialState === 'function') {
@@ -272,6 +276,7 @@ export function useReducer<S, A>(
276
} else if (initialAction !== undefined && initialAction !== null) {
277
initialState = reducer(initialState, initialAction);
278
}
279
+ currentlyRenderingComponent = component;
280
workInProgressHook.memoizedState = initialState;
281
const queue: UpdateQueue<A> = (workInProgressHook.queue = {
282
last: null,
@@ -287,7 +292,7 @@ export function useReducer<S, A>(
292
}
293
294
function useMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
290
- currentlyRenderingComponent = resolveCurrentlyRenderingComponent();
295
+ let component = (currentlyRenderingComponent = resolveCurrentlyRenderingComponent());
296
workInProgressHook = createWorkInProgressHook();
297
298
const nextDeps = deps === undefined ? null : deps;
@@ -304,7 +309,10 @@ function useMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
309
}
310
}
311
312
+ // Temporarily clear to forbid calling Hooks.
313
+ currentlyRenderingComponent = null;
314
const nextValue = nextCreate();
315
+ currentlyRenderingComponent = component;
316
workInProgressHook.memoizedState = [nextValue, nextDeps];
317
return nextValue;
318
}
packages/react-reconciler/src/ReactFiberHooks.js
+14
-3
@@ -420,7 +420,7 @@ export function useReducer<S, A>(
420
currentHookNameInDev = 'useReducer';
421
}
422
}
423
- currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
423
+ let fiber = (currentlyRenderingFiber = resolveCurrentlyRenderingFiber());
424
workInProgressHook = createWorkInProgressHook();
425
let queue: UpdateQueue<S, A> | null = (workInProgressHook.queue: any);
426
if (queue !== null) {
@@ -441,7 +441,10 @@ export function useReducer<S, A>(
441
// priority because it will always be the same as the current
442
// render's.
443
const action = update.action;
444
+ // Temporarily clear to forbid calling Hooks in a reducer.
445
+ currentlyRenderingFiber = null;
446
newState = reducer(newState, action);
447
+ currentlyRenderingFiber = fiber;
448
update = update.next;
449
} while (update !== null);
450
@@ -510,7 +513,10 @@ export function useReducer<S, A>(
513
newState = ((update.eagerState: any): S);
514
} else {
515
const action = update.action;
516
+ // Temporarily clear to forbid calling Hooks in a reducer.
517
+ currentlyRenderingFiber = null;
518
newState = reducer(newState, action);
519
+ currentlyRenderingFiber = fiber;
520
}
521
}
522
prevUpdate = update;
@@ -539,7 +545,8 @@ export function useReducer<S, A>(
545
const dispatch: Dispatch<A> = (queue.dispatch: any);
546
return [workInProgressHook.memoizedState, dispatch];
547
}
542
-
548
+ // Temporarily clear to forbid calling Hooks in a reducer.
549
+ currentlyRenderingFiber = null;
550
// There's no existing queue, so this is the initial render.
551
if (reducer === basicStateReducer) {
552
// Special case for `useState`.
@@ -549,6 +556,7 @@ export function useReducer<S, A>(
556
} else if (initialAction !== undefined && initialAction !== null) {
557
initialState = reducer(initialState, initialAction);
558
}
559
+ currentlyRenderingFiber = fiber;
560
workInProgressHook.memoizedState = workInProgressHook.baseState = initialState;
561
queue = workInProgressHook.queue = {
562
last: null,
@@ -739,7 +747,7 @@ export function useMemo<T>(
747
if (__DEV__) {
748
currentHookNameInDev = 'useMemo';
749
}
742
- currentlyRenderingFiber = resolveCurrentlyRenderingFiber();
750
+ let fiber = (currentlyRenderingFiber = resolveCurrentlyRenderingFiber());
751
workInProgressHook = createWorkInProgressHook();
752
753
const nextDeps = deps === undefined ? null : deps;
@@ -755,7 +763,10 @@ export function useMemo<T>(
763
}
764
}
765
766
+ // Temporarily clear to forbid calling Hooks.
767
+ currentlyRenderingFiber = null;
768
const nextValue = nextCreate();
769
+ currentlyRenderingFiber = fiber;
770
workInProgressHook.memoizedState = [nextValue, nextDeps];
771
return nextValue;
772
}
packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+79
@@ -517,4 +517,83 @@ describe('ReactHooks', () => {
517
const root = ReactTestRenderer.create(<App />);
518
expect(root.toJSON()).toMatchSnapshot();
519
});
520
+
521
+ it("throws when calling hooks inside .memo's compare function", () => {
522
+ const {useState} = React;
523
+ function App() {
524
+ useState(0);
525
+ return null;
526
+ }
527
+ const MemoApp = React.memo(App, () => {
528
+ useState(0);
529
+ return false;
530
+ });
531
+
532
+ const root = ReactTestRenderer.create(<MemoApp />);
533
+ // trying to render again should trigger comparison and throw
534
+ expect(() => root.update(<MemoApp />)).toThrow(
535
+ 'Hooks can only be called inside the body of a function component',
536
+ );
537
+ // the next round, it does a fresh mount, so should render
538
+ expect(() => root.update(<MemoApp />)).not.toThrow(
539
+ 'Hooks can only be called inside the body of a function component',
540
+ );
541
+ // and then again, fail
542
+ expect(() => root.update(<MemoApp />)).toThrow(
543
+ 'Hooks can only be called inside the body of a function component',
544
+ );
545
+ });
546
+
547
+ it('throws when calling hooks inside useMemo', () => {
548
+ const {useMemo, useState} = React;
549
+ function App() {
550
+ useMemo(() => {
551
+ useState(0);
552
+ return 1;
553
+ });
554
+ return null;
555
+ }
556
+
557
+ function Simple() {
558
+ const [value] = useState(123);
559
+ return value;
560
+ }
561
+ let root = ReactTestRenderer.create(null);
562
+ expect(() => root.update(<App />)).toThrow(
563
+ 'Hooks can only be called inside the body of a function component',
564
+ );
565
+
566
+ // we want to assure that no hook machinery has broken
567
+ // so we render a fresh component with a hook just to be sure
568
+ root.update(<Simple />);
569
+ expect(root.toJSON()).toEqual('123');
570
+ });
571
+
572
+ it('throws when calling hooks inside useReducer', () => {
573
+ const {useReducer, useRef} = React;
574
+ function App() {
575
+ const [value, dispatch] = useReducer((state, action) => {
576
+ useRef(0);
577
+ return state;
578
+ }, 0);
579
+ dispatch('foo');
580
+ return value;
581
+ }
582
+ expect(() => ReactTestRenderer.create(<App />)).toThrow(
583
+ 'Hooks can only be called inside the body of a function component',
584
+ );
585
+ });
586
+
587
+ it("throws when calling hooks inside useState's initialize function", () => {
588
+ const {useState, useRef} = React;
589
+ function App() {
590
+ useState(() => {
591
+ useRef(0);
592
+ return 0;
593
+ });
594
+ }
595
+ expect(() => ReactTestRenderer.create(<App />)).toThrow(
596
+ 'Hooks can only be called inside the body of a function component',
597
+ );
598
+ });
599
});