@samitouri / QOS-React / commits / e1cd83e49d

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 });