From cb1ff430e8c473a8a6bddd592106891251bbf5bf Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 29 Jan 2019 16:32:15 -0800 Subject: [PATCH] Phased dispatcher (#14701) * Move DEV-only function right above where it's used I don't like looking at this top-level function #petty * Use different dispatchers for functions & classes Classes support readContext, but not any of the other dispatcher methods. Function support all methods. This is a more robust version of our previous strategy of checking whether `currentlyRenderingFiber` is null. As a next step, we can use a separate dispatcher for each phase of the render cycle (mount versus update). * Use separate dispatchers for mount and update * Remove mount code from update path Deletes mount-specific code from the update path, since it should be unreachable. To continue supporting progressive enhancement (mounting new hooks at the end of the list), we detect when there are no more current hooks and switch back to the mount dispatcher. Progressive enhancement isn't officially supported yet, so it will continue to warn. * Factoring nits * Fix Flow Had to cheat more than I would like * More Flow nits * Switch back to using a special dispatcher for nested hooks in DEV In order for this strategy to work, I had to revert progressive enhancement support (appending hooks to the end). It was previously a warning but now it results in an error. We'll reconsider later. * Always pass args to updateState and updateReducer Even though the extra args are only used on mount, to ensure type consistency. --- .../react-debug-tools/src/ReactDebugHooks.js | 2 +- ...DOMServerIntegrationHooks-test.internal.js | 30 +- .../src/server/ReactPartialRendererHooks.js | 5 +- .../src/ReactFiberDispatcher.js | 36 - .../react-reconciler/src/ReactFiberHooks.js | 1437 +++++++++++------ .../src/ReactFiberScheduler.js | 7 +- .../src/__tests__/ReactHooks-test.internal.js | 23 +- ...eactHooksWithNoopRenderer-test.internal.js | 42 +- .../src/ReactShallowRenderer.js | 2 +- packages/react/src/ReactCurrentDispatcher.js | 2 +- 10 files changed, 1015 insertions(+), 571 deletions(-) delete mode 100644 packages/react-reconciler/src/ReactFiberDispatcher.js diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index b53fab87d4..426d20a132 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -10,7 +10,7 @@ import type {ReactContext, ReactProviderType} from 'shared/ReactTypes'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import type {Hook} from 'react-reconciler/src/ReactFiberHooks'; -import typeof {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberDispatcher'; +import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberHooks'; import ErrorStackParser from 'error-stack-parser'; import ReactSharedInternals from 'shared/ReactSharedInternals'; diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js index d3a7dae612..5fa5c14f49 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.internal.js @@ -432,21 +432,25 @@ describe('ReactDOMServerHooks', () => { expect(domNode.textContent).toEqual('hi'); }); - itRenders('with a warning for useRef inside useReducer', async render => { - function App() { - const [value, dispatch] = useReducer((state, action) => { - useRef(0); - return state + 1; - }, 0); - if (value === 0) { - dispatch(); + itThrowsWhenRendering( + 'with a warning for useRef inside useReducer', + async render => { + function App() { + const [value, dispatch] = useReducer((state, action) => { + useRef(0); + return state + 1; + }, 0); + if (value === 0) { + dispatch(); + } + return value; } - return value; - } - const domNode = await render(, 1); - expect(domNode.textContent).toEqual('1'); - }); + const domNode = await render(, 1); + expect(domNode.textContent).toEqual('1'); + }, + 'Rendered more hooks than during the previous render', + ); itRenders('with a warning for useRef inside useState', async render => { function App() { diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 1689d830c1..c462487e38 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -7,7 +7,7 @@ * @flow */ -import typeof {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberDispatcher'; +import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberHooks'; import type {ThreadID} from './ReactThreadIDAllocator'; import type {ReactContext} from 'shared/ReactTypes'; @@ -113,6 +113,9 @@ function areHookInputsEqual( } function createHook(): Hook { + if (numberOfReRenders > 0) { + invariant(false, 'Rendered more hooks than during the previous render'); + } return { memoizedState: null, queue: null, diff --git a/packages/react-reconciler/src/ReactFiberDispatcher.js b/packages/react-reconciler/src/ReactFiberDispatcher.js deleted file mode 100644 index f1572072b3..0000000000 --- a/packages/react-reconciler/src/ReactFiberDispatcher.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import {readContext} from './ReactFiberNewContext'; -import { - useCallback, - useContext, - useEffect, - useImperativeHandle, - useDebugValue, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, -} from './ReactFiberHooks'; - -export const Dispatcher = { - readContext, - useCallback, - useContext, - useEffect, - useImperativeHandle, - useDebugValue, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, -}; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 5965f4d77e..2955a0e55e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -8,16 +8,15 @@ */ import type {ReactContext} from 'shared/ReactTypes'; +import type {SideEffectTag} from 'shared/ReactSideEffectTags'; import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {HookEffectTag} from './ReactHookEffectTags'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; + import {NoWork} from './ReactFiberExpirationTime'; -import { - readContext, - enterDisallowedContextReadInDEV, - exitDisallowedContextReadInDEV, -} from './ReactFiberNewContext'; +import {readContext} from './ReactFiberNewContext'; import { Update as UpdateEffect, Passive as PassiveEffect, @@ -42,6 +41,36 @@ import getComponentName from 'shared/getComponentName'; import is from 'shared/objectIs'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; +const {ReactCurrentDispatcher} = ReactSharedInternals; + +export type Dispatcher = { + readContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T, + useState(initialState: (() => S) | S): [S, Dispatch>], + useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, + ): [S, Dispatch], + useContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T, + useRef(initialValue: T): {current: T}, + useEffect(create: () => mixed, deps: Array | void | null): void, + useLayoutEffect(create: () => mixed, deps: Array | void | null): void, + useCallback(callback: T, deps: Array | void | null): T, + useMemo(nextCreate: () => T, deps: Array | void | null): T, + useImperativeHandle( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + deps: Array | void | null, + ): void, + useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void, +}; + type Update = { expirationTime: ExpirationTime, action: A, @@ -72,7 +101,6 @@ type HookType = // the first instance of a hook mismatch in a component, // represented by a portion of its stacktrace let currentHookMismatchInDev = null; -let isInHookUserCodeInDev = false; let didWarnAboutMismatchedHooksForComponent; if (__DEV__) { @@ -121,11 +149,14 @@ let currentlyRenderingFiber: Fiber | null = null; // work-in-progress fiber. let firstCurrentHook: Hook | null = null; let currentHook: Hook | null = null; +let nextCurrentHook: Hook | null = null; let firstWorkInProgressHook: Hook | null = null; let workInProgressHook: Hook | null = null; +let nextWorkInProgressHook: Hook | null = null; let remainingExpirationTime: ExpirationTime = NoWork; let componentUpdateQueue: FunctionComponentUpdateQueue | null = null; +let sideEffectTag: SideEffectTag = 0; // Updates scheduled during render will trigger an immediate re-render at the // end of the current pass. We can't store these updates on the normal queue, @@ -143,29 +174,17 @@ let renderPhaseUpdates: Map< Update, > | null = null; // Counter to prevent infinite loops. -let numberOfReRenders: number = -1; +let numberOfReRenders: number = 0; const RE_RENDER_LIMIT = 25; // In DEV, this is the name of the currently executing primitive hook let currentHookNameInDev: ?HookType = null; -function resolveCurrentlyRenderingFiber(): Fiber { +function throwInvalidHookError() { invariant( - currentlyRenderingFiber !== null, + false, 'Hooks can only be called inside the body of a function component.', ); - if (__DEV__) { - // Check if we're inside Hooks like useMemo(). DEV-only for perf. - // TODO: we can make a better warning message with currentHookNameInDev - // if we also make sure it's consistently assigned in the right order. - warning( - !isInHookUserCodeInDev, - 'Hooks can only be called inside the body of a function component. ' + - 'Do not call Hooks inside other Hooks. For more information, see ' + - 'https://fb.me/rules-of-hooks', - ); - } - return currentlyRenderingFiber; } function areHookInputsEqual( @@ -210,18 +229,6 @@ 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 @@ -254,6 +261,13 @@ function flushHookMismatchWarnings() { .concat(' Previous render'.length), ); + const padEndSpaces = (string, length) => { + if (string.length >= length) { + return string; + } + return string + ' ' + new Array(length - string.length).join(' '); + }; + let hookStackHeader = ((padEndSpaces(' Previous render', columnLength): any): string) + ' Next render\n'; @@ -309,12 +323,8 @@ export function renderWithHooks( ): any { renderExpirationTime = nextRenderExpirationTime; currentlyRenderingFiber = workInProgress; - firstCurrentHook = current !== null ? current.memoizedState : null; - - if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; - } + firstCurrentHook = nextCurrentHook = + current !== null ? current.memoizedState : null; // The following should have already been reset // currentHook = null; @@ -325,45 +335,63 @@ export function renderWithHooks( // didScheduleRenderPhaseUpdate = false; // renderPhaseUpdates = null; - // numberOfReRenders = -1; + // numberOfReRenders = 0; + // sideEffectTag = 0; - let children; - do { - didScheduleRenderPhaseUpdate = false; - numberOfReRenders += 1; + if (__DEV__) { + ReactCurrentDispatcher.current = + nextCurrentHook === null + ? HooksDispatcherOnMountInDEV + : HooksDispatcherOnUpdateInDEV; + } else { + ReactCurrentDispatcher.current = + nextCurrentHook === null + ? HooksDispatcherOnMount + : HooksDispatcherOnUpdate; + } - // Start over from the beginning of the list - currentHook = null; - workInProgressHook = null; - componentUpdateQueue = null; + let children = Component(props, refOrContext); - children = Component(props, refOrContext); + if (didScheduleRenderPhaseUpdate) { + do { + didScheduleRenderPhaseUpdate = false; + numberOfReRenders += 1; - if (__DEV__) { - if ( - current !== null && - workInProgressHook !== null && - currentHook === null - ) { - warning( - false, - '%s: Rendered more hooks than during the previous render. This is ' + - 'not currently supported and may lead to unexpected behavior.', - getComponentName(Component), - ); - } - flushHookMismatchWarnings(); - } - } while (didScheduleRenderPhaseUpdate); + // Start over from the beginning of the list + firstCurrentHook = nextCurrentHook = + current !== null ? current.memoizedState : null; + nextWorkInProgressHook = firstWorkInProgressHook; - renderPhaseUpdates = null; - numberOfReRenders = -1; + currentHook = null; + workInProgressHook = null; + componentUpdateQueue = null; + + ReactCurrentDispatcher.current = __DEV__ + ? HooksDispatcherOnUpdateInDEV + : HooksDispatcherOnUpdate; + + children = Component(props, refOrContext); + } while (didScheduleRenderPhaseUpdate); + + renderPhaseUpdates = null; + numberOfReRenders = 0; + } + + if (__DEV__) { + flushHookMismatchWarnings(); + currentHookNameInDev = null; + } + + // We can assume the previous dispatcher is always this one, since we set it + // at the beginning of the render phase and there's no re-entrancy. + ReactCurrentDispatcher.current = ContextOnlyDispatcher; const renderedWork: Fiber = (currentlyRenderingFiber: any); renderedWork.memoizedState = firstWorkInProgressHook; renderedWork.expirationTime = remainingExpirationTime; - renderedWork.updateQueue = componentUpdateQueue; + renderedWork.updateQueue = (componentUpdateQueue: any); + renderedWork.effectTag |= sideEffectTag; const didRenderTooFewHooks = currentHook !== null && currentHook.next !== null; @@ -373,20 +401,19 @@ export function renderWithHooks( firstCurrentHook = null; currentHook = null; + nextCurrentHook = null; firstWorkInProgressHook = null; workInProgressHook = null; + nextWorkInProgressHook = null; remainingExpirationTime = NoWork; componentUpdateQueue = null; - - if (__DEV__) { - currentHookNameInDev = null; - } + sideEffectTag = 0; // These were reset above // didScheduleRenderPhaseUpdate = false; // renderPhaseUpdates = null; - // numberOfReRenders = -1; + // numberOfReRenders = 0; invariant( !didRenderTooFewHooks, @@ -412,10 +439,12 @@ export function bailoutHooks( export function resetHooks(): void { if (__DEV__) { flushHookMismatchWarnings(); - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; } + // We can assume the previous dispatcher is always this one, since we set it + // at the beginning of the render phase and there's no re-entrancy. + ReactCurrentDispatcher.current = ContextOnlyDispatcher; + // This is used to reset the state of this module when a component throws. // It's also called inside mountIndeterminateComponent if we determine the // component is a module-style component. @@ -424,11 +453,14 @@ export function resetHooks(): void { firstCurrentHook = null; currentHook = null; + nextCurrentHook = null; firstWorkInProgressHook = null; workInProgressHook = null; + nextWorkInProgressHook = null; remainingExpirationTime = NoWork; componentUpdateQueue = null; + sideEffectTag = 0; if (__DEV__) { currentHookNameInDev = null; @@ -436,110 +468,95 @@ export function resetHooks(): void { didScheduleRenderPhaseUpdate = false; renderPhaseUpdates = null; - numberOfReRenders = -1; + numberOfReRenders = 0; } -function createHook(): Hook { - let hook: Hook = __DEV__ - ? { - _debugType: ((currentHookNameInDev: any): HookType), - memoizedState: null, +function mountWorkInProgressHook(): Hook { + const hook: Hook = { + memoizedState: null, - baseState: null, - queue: null, - baseUpdate: null, + baseState: null, + queue: null, + baseUpdate: null, - next: null, - } - : { - memoizedState: null, - - baseState: null, - queue: null, - baseUpdate: null, - - next: null, - }; - - return hook; -} - -function cloneHook(hook: Hook): Hook { - let nextHook: Hook = __DEV__ - ? { - _debugType: ((currentHookNameInDev: any): HookType), - memoizedState: hook.memoizedState, - - baseState: hook.baseState, - queue: hook.queue, - baseUpdate: hook.baseUpdate, - - next: null, - } - : { - memoizedState: hook.memoizedState, - - baseState: hook.baseState, - queue: hook.queue, - baseUpdate: hook.baseUpdate, - - next: null, - }; + next: null, + }; if (__DEV__) { - if (currentHookMismatchInDev === null) { - if (currentHookNameInDev !== ((hook: any): HookDev)._debugType) { - currentHookMismatchInDev = new Error('tracer').stack - .split('\n') - .slice(4) - .join('\n'); - } + (hook: any)._debugType = (currentHookNameInDev: any); + if ( + currentlyRenderingFiber !== null && + currentlyRenderingFiber.alternate !== null + ) { + warning( + false, + '%s: Rendered more hooks than during the previous render. This is ' + + 'not currently supported and may lead to unexpected behavior.', + getComponentName(currentlyRenderingFiber.type), + ); } } - return nextHook; -} - -function createWorkInProgressHook(): Hook { if (workInProgressHook === null) { // This is the first hook in the list - if (firstWorkInProgressHook === null) { - currentHook = firstCurrentHook; - if (currentHook === null) { - // This is a newly mounted hook - workInProgressHook = createHook(); - } else { - // Clone the current hook. - workInProgressHook = cloneHook(currentHook); - } - firstWorkInProgressHook = workInProgressHook; - } else { - // There's already a work-in-progress. Reuse it. - currentHook = firstCurrentHook; - workInProgressHook = firstWorkInProgressHook; - } + firstWorkInProgressHook = workInProgressHook = hook; } else { - if (workInProgressHook.next === null) { - let hook; - if (currentHook === null) { - // This is a newly mounted hook - hook = createHook(); - } else { - currentHook = currentHook.next; - if (currentHook === null) { - // This is a newly mounted hook - hook = createHook(); - } else { - // Clone the current hook. - hook = cloneHook(currentHook); + // Append to the end of the list + workInProgressHook = workInProgressHook.next = hook; + } + return workInProgressHook; +} + +function updateWorkInProgressHook(): Hook { + // This function is used both for updates and for re-renders triggered by a + // render phase update. It assumes there is either a current hook we can + // clone, or a work-in-progress hook from a previous render pass that we can + // use as a base. When we reach the end of the base list, we must switch to + // the dispatcher used for mounts. + if (nextWorkInProgressHook !== null) { + // There's already a work-in-progress. Reuse it. + workInProgressHook = nextWorkInProgressHook; + nextWorkInProgressHook = workInProgressHook.next; + + currentHook = nextCurrentHook; + nextCurrentHook = currentHook !== null ? currentHook.next : null; + } else { + // Clone from the current hook. + invariant( + nextCurrentHook !== null, + 'Rendered more hooks than during the previous render.', + ); + currentHook = nextCurrentHook; + + const newHook: Hook = { + memoizedState: currentHook.memoizedState, + + baseState: currentHook.baseState, + queue: currentHook.queue, + baseUpdate: currentHook.baseUpdate, + + next: null, + }; + + if (__DEV__) { + (newHook: any)._debugType = (currentHookNameInDev: any); + if (currentHookMismatchInDev === null) { + if (currentHookNameInDev !== ((currentHook: any): HookDev)._debugType) { + currentHookMismatchInDev = new Error('tracer').stack + .split('\n') + .slice(4) + .join('\n'); } } - // Append to the end of the list - workInProgressHook = workInProgressHook.next = hook; - } else { - // There's already a work-in-progress. Reuse it. - workInProgressHook = workInProgressHook.next; - currentHook = currentHook !== null ? currentHook.next : null; } + + if (workInProgressHook === null) { + // This is the first hook in the list. + workInProgressHook = firstWorkInProgressHook = newHook; + } else { + // Append to the end of the list. + workInProgressHook = workInProgressHook.next = newHook; + } + nextCurrentHook = currentHook.next; } return workInProgressHook; } @@ -554,212 +571,216 @@ function basicStateReducer(state: S, action: BasicStateAction): S { return typeof action === 'function' ? action(state) : action; } -export function useContext( +function mountContext( context: ReactContext, observedBits: void | number | boolean, ): T { if (__DEV__) { - currentHookNameInDev = 'useContext'; - createWorkInProgressHook(); - currentHookNameInDev = null; + mountWorkInProgressHook(); } - // Ensure we're in a function component (class components support only the - // .unstable_read() form) - resolveCurrentlyRenderingFiber(); return readContext(context, observedBits); } -export function useState( - initialState: (() => S) | S, -): [S, Dispatch>] { +function updateContext( + context: ReactContext, + observedBits: void | number | boolean, +): T { if (__DEV__) { - currentHookNameInDev = 'useState'; + updateWorkInProgressHook(); } - return useReducer( - basicStateReducer, - // useReducer has a special case to support lazy useState initializers - (initialState: any), - ); + return readContext(context, observedBits); } -export function useReducer( +function mountReducer( reducer: (S, A) => S, - initialState: S, - initialAction: A | void | null, + initialState: void | S, + initialAction: void | null | A, ): [S, Dispatch] { - if (__DEV__) { - if (reducer !== basicStateReducer) { - currentHookNameInDev = 'useReducer'; - } - } - currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); - workInProgressHook = createWorkInProgressHook(); - if (__DEV__) { - currentHookNameInDev = null; - } - let queue: UpdateQueue | null = (workInProgressHook.queue: any); - if (queue !== null) { - // Already have a queue, so this is an update. - if (numberOfReRenders > 0) { - // This is a re-render. Apply the new render phase updates to the previous - // work-in-progress hook. - const dispatch: Dispatch = (queue.dispatch: any); - if (renderPhaseUpdates !== null) { - // Render phase updates are stored in a map of queue -> linked list - const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); - if (firstRenderPhaseUpdate !== undefined) { - renderPhaseUpdates.delete(queue); - let newState = workInProgressHook.memoizedState; - let update = firstRenderPhaseUpdate; - do { - // Process this render phase update. We don't have to check the - // priority because it will always be the same as the current - // render's. - const action = update.action; - if (__DEV__) { - enterDisallowedContextReadInDEV(); - isInHookUserCodeInDev = true; - } - newState = reducer(newState, action); - if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; - } - update = update.next; - } while (update !== null); - - workInProgressHook.memoizedState = newState; - - // Don't persist the state accumlated from the render phase updates to - // the base state unless the queue is empty. - // TODO: Not sure if this is the desired semantics, but it's what we - // do for gDSFP. I can't remember why. - if (workInProgressHook.baseUpdate === queue.last) { - workInProgressHook.baseState = newState; - } - - return [newState, dispatch]; - } - } - return [workInProgressHook.memoizedState, dispatch]; - } - - // The last update in the entire queue - const last = queue.last; - // The last update that is part of the base state. - const baseUpdate = workInProgressHook.baseUpdate; - const baseState = workInProgressHook.baseState; - - // Find the first unprocessed update. - let first; - if (baseUpdate !== null) { - if (last !== null) { - // For the first update, the queue is a circular linked list where - // `queue.last.next = queue.first`. Once the first update commits, and - // the `baseUpdate` is no longer empty, we can unravel the list. - last.next = null; - } - first = baseUpdate.next; - } else { - first = last !== null ? last.next : null; - } - if (first !== null) { - let newState = baseState; - let newBaseState = null; - let newBaseUpdate = null; - let prevUpdate = baseUpdate; - let update = first; - let didSkip = false; - do { - const updateExpirationTime = update.expirationTime; - if (updateExpirationTime < renderExpirationTime) { - // Priority is insufficient. Skip this update. If this is the first - // skipped update, the previous update/state is the new base - // update/state. - if (!didSkip) { - didSkip = true; - newBaseUpdate = prevUpdate; - newBaseState = newState; - } - // Update the remaining priority in the queue. - if (updateExpirationTime > remainingExpirationTime) { - remainingExpirationTime = updateExpirationTime; - } - } else { - // Process this update. - if (update.eagerReducer === reducer) { - // If this update was processed eagerly, and its reducer matches the - // current reducer, we can use the eagerly computed state. - newState = ((update.eagerState: any): S); - } else { - const action = update.action; - if (__DEV__) { - enterDisallowedContextReadInDEV(); - isInHookUserCodeInDev = true; - } - newState = reducer(newState, action); - if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; - } - } - } - prevUpdate = update; - update = update.next; - } while (update !== null && update !== first); - - if (!didSkip) { - newBaseUpdate = prevUpdate; - newBaseState = newState; - } - - workInProgressHook.memoizedState = newState; - workInProgressHook.baseUpdate = newBaseUpdate; - workInProgressHook.baseState = newBaseState; - - // Mark that the fiber performed work, but only if the new state is - // different from the current state. - if (newState !== (currentHook: any).memoizedState) { - markWorkInProgressReceivedUpdate(); - } - - queue.eagerReducer = reducer; - queue.eagerState = newState; - } - - const dispatch: Dispatch = (queue.dispatch: any); - return [workInProgressHook.memoizedState, dispatch]; - } - if (__DEV__) { - enterDisallowedContextReadInDEV(); - isInHookUserCodeInDev = true; - } - // There's no existing queue, so this is the initial render. - if (reducer === basicStateReducer) { - // Special case for `useState`. - if (typeof initialState === 'function') { - initialState = initialState(); - } - } else if (initialAction !== undefined && initialAction !== null) { + const hook = mountWorkInProgressHook(); + // TODO: Lazy init API will change before release. + if (initialAction !== undefined && initialAction !== null) { + // $FlowFixMe - Must express with overloading. initialState = reducer(initialState, initialAction); } - if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; - } - workInProgressHook.memoizedState = workInProgressHook.baseState = initialState; - queue = workInProgressHook.queue = { + hook.memoizedState = hook.baseState = initialState; + const queue = (hook.queue = { last: null, dispatch: null, eagerReducer: reducer, - eagerState: initialState, - }; + eagerState: (initialState: any), + }); const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( null, - currentlyRenderingFiber, + // Flow doesn't know this is non-null, but we do. + ((currentlyRenderingFiber: any): Fiber), queue, ): any)); - return [workInProgressHook.memoizedState, dispatch]; + return [hook.memoizedState, dispatch]; +} + +function updateReducer( + reducer: (S, A) => S, + initialState: void | S, + initialAction: void | null | A, +): [S, Dispatch] { + const hook = updateWorkInProgressHook(); + const queue = hook.queue; + invariant( + queue !== null, + 'Should have a queue. This is likely a bug in React. Please file an issue.', + ); + + if (numberOfReRenders > 0) { + // This is a re-render. Apply the new render phase updates to the previous + // work-in-progress hook. + const dispatch: Dispatch = (queue.dispatch: any); + if (renderPhaseUpdates !== null) { + // Render phase updates are stored in a map of queue -> linked list + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate !== undefined) { + renderPhaseUpdates.delete(queue); + let newState = hook.memoizedState; + let update = firstRenderPhaseUpdate; + do { + // Process this render phase update. We don't have to check the + // priority because it will always be the same as the current + // render's. + const action = update.action; + newState = reducer(newState, action); + update = update.next; + } while (update !== null); + + // Mark that the fiber performed work, but only if the new state is + // different from the current state. + if (newState !== hook.memoizedState) { + markWorkInProgressReceivedUpdate(); + } + + hook.memoizedState = newState; + + // Don't persist the state accumlated from the render phase updates to + // the base state unless the queue is empty. + // TODO: Not sure if this is the desired semantics, but it's what we + // do for gDSFP. I can't remember why. + if (hook.baseUpdate === queue.last) { + hook.baseState = newState; + } + + return [newState, dispatch]; + } + } + return [hook.memoizedState, dispatch]; + } + + // The last update in the entire queue + const last = queue.last; + // The last update that is part of the base state. + const baseUpdate = hook.baseUpdate; + const baseState = hook.baseState; + + // Find the first unprocessed update. + let first; + if (baseUpdate !== null) { + if (last !== null) { + // For the first update, the queue is a circular linked list where + // `queue.last.next = queue.first`. Once the first update commits, and + // the `baseUpdate` is no longer empty, we can unravel the list. + last.next = null; + } + first = baseUpdate.next; + } else { + first = last !== null ? last.next : null; + } + if (first !== null) { + let newState = baseState; + let newBaseState = null; + let newBaseUpdate = null; + let prevUpdate = baseUpdate; + let update = first; + let didSkip = false; + do { + const updateExpirationTime = update.expirationTime; + if (updateExpirationTime < renderExpirationTime) { + // Priority is insufficient. Skip this update. If this is the first + // skipped update, the previous update/state is the new base + // update/state. + if (!didSkip) { + didSkip = true; + newBaseUpdate = prevUpdate; + newBaseState = newState; + } + // Update the remaining priority in the queue. + if (updateExpirationTime > remainingExpirationTime) { + remainingExpirationTime = updateExpirationTime; + } + } else { + // Process this update. + if (update.eagerReducer === reducer) { + // If this update was processed eagerly, and its reducer matches the + // current reducer, we can use the eagerly computed state. + newState = ((update.eagerState: any): S); + } else { + const action = update.action; + newState = reducer(newState, action); + } + } + prevUpdate = update; + update = update.next; + } while (update !== null && update !== first); + + if (!didSkip) { + newBaseUpdate = prevUpdate; + newBaseState = newState; + } + + // Mark that the fiber performed work, but only if the new state is + // different from the current state. + if (newState !== hook.memoizedState) { + markWorkInProgressReceivedUpdate(); + } + + hook.memoizedState = newState; + hook.baseUpdate = newBaseUpdate; + hook.baseState = newBaseState; + + queue.eagerReducer = reducer; + queue.eagerState = newState; + } + + const dispatch: Dispatch = (queue.dispatch: any); + return [hook.memoizedState, dispatch]; +} + +function mountState( + initialState: (() => S) | S, +): [S, Dispatch>] { + const hook = mountWorkInProgressHook(); + // TODO: Lazy init API will change before release. + if (typeof initialState === 'function') { + initialState = initialState(); + } + hook.memoizedState = hook.baseState = initialState; + const queue = (hook.queue = { + last: null, + dispatch: null, + eagerReducer: basicStateReducer, + eagerState: (initialState: any), + }); + const dispatch: Dispatch< + BasicStateAction, + > = (queue.dispatch = (dispatchAction.bind( + null, + // Flow doesn't know this is non-null, but we do. + ((currentlyRenderingFiber: any): Fiber), + queue, + ): any)); + return [hook.memoizedState, dispatch]; +} + +function updateState( + initialState: (() => S) | S, +): [S, Dispatch>] { + return updateReducer(basicStateReducer, (initialState: any)); } function pushEffect(tag, create, destroy, deps) { @@ -788,62 +809,33 @@ function pushEffect(tag, create, destroy, deps) { return effect; } -export function useRef(initialValue: T): {current: T} { - currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); +function mountRef(initialValue: T): {current: T} { + const hook = mountWorkInProgressHook(); + const ref = {current: initialValue}; if (__DEV__) { - currentHookNameInDev = 'useRef'; - } - workInProgressHook = createWorkInProgressHook(); - if (__DEV__) { - currentHookNameInDev = null; - } - let ref; - - if (workInProgressHook.memoizedState === null) { - ref = {current: initialValue}; - if (__DEV__) { - Object.seal(ref); - } - workInProgressHook.memoizedState = ref; - } else { - ref = workInProgressHook.memoizedState; + Object.seal(ref); } + hook.memoizedState = ref; return ref; } -export function useLayoutEffect( - create: () => mixed, - deps: Array | void | null, -): void { - if (__DEV__) { - if (currentHookNameInDev !== 'useImperativeHandle') { - currentHookNameInDev = 'useLayoutEffect'; - } - } - useEffectImpl(UpdateEffect, UnmountMutation | MountLayout, create, deps); +function updateRef(initialValue: T): {current: T} { + const hook = updateWorkInProgressHook(); + return hook.memoizedState; } -export function useEffect( - create: () => mixed, - deps: Array | void | null, -): void { - if (__DEV__) { - currentHookNameInDev = 'useEffect'; - } - useEffectImpl( - UpdateEffect | PassiveEffect, - UnmountPassive | MountPassive, - create, - deps, - ); +function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void { + const hook = mountWorkInProgressHook(); + const nextDeps = deps === undefined ? null : deps; + sideEffectTag |= fiberEffectTag; + hook.memoizedState = pushEffect(hookEffectTag, create, null, nextDeps); } -function useEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void { - currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); - workInProgressHook = createWorkInProgressHook(); - +function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void { + const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; let destroy = null; + if (currentHook !== null) { const prevEffect = currentHook.memoizedState; destroy = prevEffect.destroy; @@ -851,33 +843,96 @@ function useEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void { const prevDeps = prevEffect.deps; if (areHookInputsEqual(nextDeps, prevDeps)) { pushEffect(NoHookEffect, create, destroy, nextDeps); - if (__DEV__) { - currentHookNameInDev = null; - } return; } } } - currentlyRenderingFiber.effectTag |= fiberEffectTag; - workInProgressHook.memoizedState = pushEffect( - hookEffectTag, + sideEffectTag |= fiberEffectTag; + hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps); +} + +function mountEffect( + create: () => mixed, + deps: Array | void | null, +): void { + return mountEffectImpl( + UpdateEffect | PassiveEffect, + UnmountPassive | MountPassive, create, - destroy, - nextDeps, + deps, ); - if (__DEV__) { - currentHookNameInDev = null; +} + +function updateEffect( + create: () => mixed, + deps: Array | void | null, +): void { + return updateEffectImpl( + UpdateEffect | PassiveEffect, + UnmountPassive | MountPassive, + create, + deps, + ); +} + +function mountLayoutEffect( + create: () => mixed, + deps: Array | void | null, +): void { + return mountEffectImpl( + UpdateEffect, + UnmountMutation | MountLayout, + create, + deps, + ); +} + +function updateLayoutEffect( + create: () => mixed, + deps: Array | void | null, +): void { + return updateEffectImpl( + UpdateEffect, + UnmountMutation | MountLayout, + create, + deps, + ); +} + +function imperativeHandleEffect( + create: () => T, + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, +) { + if (typeof ref === 'function') { + const refCallback = ref; + const inst = create(); + refCallback(inst); + return () => refCallback(null); + } else if (ref !== null && ref !== undefined) { + const refObject = ref; + if (__DEV__) { + warning( + refObject.hasOwnProperty('current'), + 'Expected useImperativeHandle() first argument to either be a ' + + 'ref callback or React.createRef() object. Instead received: %s.', + 'an object with keys {' + Object.keys(refObject).join(', ') + '}', + ); + } + const inst = create(); + refObject.current = inst; + return () => { + refObject.current = null; + }; } } -export function useImperativeHandle( +function mountImperativeHandle( ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, create: () => T, deps: Array | void | null, ): void { if (__DEV__) { - currentHookNameInDev = 'useImperativeHandle'; warning( typeof create === 'function', 'Expected useImperativeHandle() second argument to be a function ' + @@ -885,122 +940,105 @@ export function useImperativeHandle( create !== null ? typeof create : 'null', ); } + // TODO: If deps are provided, should we skip comparing the ref itself? - const nextDeps = + const effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : [ref]; - // TODO: I've implemented this on top of useEffect because it's almost the - // same thing, and it would require an equal amount of code. It doesn't seem - // like a common enough use case to justify the additional size. - useLayoutEffect(() => { - if (typeof ref === 'function') { - const refCallback = ref; - const inst = create(); - refCallback(inst); - return () => refCallback(null); - } else if (ref !== null && ref !== undefined) { - const refObject = ref; - if (__DEV__) { - warning( - refObject.hasOwnProperty('current'), - 'Expected useImperativeHandle() first argument to either be a ' + - 'ref callback or React.createRef() object. Instead received: %s.', - 'an object with keys {' + Object.keys(refObject).join(', ') + '}', - ); - } - const inst = create(); - refObject.current = inst; - return () => { - refObject.current = null; - }; - } - }, nextDeps); + return mountEffectImpl( + UpdateEffect, + UnmountMutation | MountLayout, + imperativeHandleEffect.bind(null, create, ref), + effectDeps, + ); } -export function useDebugValue( - value: any, - formatterFn: ?(value: any) => any, +function updateImperativeHandle( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + deps: Array | void | null, ): void { if (__DEV__) { - currentHookNameInDev = 'useDebugValue'; + warning( + typeof create === 'function', + 'Expected useImperativeHandle() second argument to be a function ' + + 'that creates a handle. Instead received: %s.', + create !== null ? typeof create : 'null', + ); } - // This will trigger a warning if the hook is used in a non-Function component. - resolveCurrentlyRenderingFiber(); + // TODO: If deps are provided, should we skip comparing the ref itself? + const effectDeps = + deps !== null && deps !== undefined ? deps.concat([ref]) : [ref]; + return updateEffectImpl( + UpdateEffect, + UnmountMutation | MountLayout, + imperativeHandleEffect.bind(null, create, ref), + effectDeps, + ); +} + +function mountDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { // This hook is normally a no-op. // The react-debug-hooks package injects its own implementation // so that e.g. DevTools can display custom hook values. } -export function useCallback( - callback: T, - deps: Array | void | null, -): T { - if (__DEV__) { - currentHookNameInDev = 'useCallback'; - } - currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); - workInProgressHook = createWorkInProgressHook(); +const updateDebugValue = mountDebugValue; +function mountCallback(callback: T, deps: Array | void | null): T { + const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; + hook.memoizedState = [callback, nextDeps]; + return callback; +} - const prevState = workInProgressHook.memoizedState; +function updateCallback(callback: T, deps: Array | void | null): T { + const hook = updateWorkInProgressHook(); + const nextDeps = deps === undefined ? null : deps; + const prevState = hook.memoizedState; if (prevState !== null) { if (nextDeps !== null) { const prevDeps: Array | null = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { - currentHookNameInDev = null; return prevState[0]; } } } - workInProgressHook.memoizedState = [callback, nextDeps]; - if (__DEV__) { - currentHookNameInDev = null; - } + hook.memoizedState = [callback, nextDeps]; return callback; } -export function useMemo( +function mountMemo( nextCreate: () => T, deps: Array | void | null, ): T { - if (__DEV__) { - currentHookNameInDev = 'useMemo'; - } - currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); - workInProgressHook = createWorkInProgressHook(); - + const hook = mountWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; + const nextValue = nextCreate(); + hook.memoizedState = [nextValue, nextDeps]; + return nextValue; +} - const prevState = workInProgressHook.memoizedState; +function updateMemo( + nextCreate: () => T, + deps: Array | void | null, +): T { + const hook = updateWorkInProgressHook(); + const nextDeps = deps === undefined ? null : deps; + const prevState = hook.memoizedState; if (prevState !== null) { // Assume these are defined. If they're not, areHookInputsEqual will warn. if (nextDeps !== null) { const prevDeps: Array | null = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { - if (__DEV__) { - currentHookNameInDev = null; - } return prevState[0]; } } } - - if (__DEV__) { - enterDisallowedContextReadInDEV(); - isInHookUserCodeInDev = true; - } const nextValue = nextCreate(); - if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; - } - workInProgressHook.memoizedState = [nextValue, nextDeps]; - if (__DEV__) { - currentHookNameInDev = null; - } + hook.memoizedState = [nextValue, nextDeps]; return nextValue; } @@ -1092,17 +1130,14 @@ function dispatchAction( // same as the current state, we may be able to bail out entirely. const eagerReducer = queue.eagerReducer; if (eagerReducer !== null) { + let prevDispatcher; + if (__DEV__) { + prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + } try { const currentState: S = (queue.eagerState: any); - if (__DEV__) { - enterDisallowedContextReadInDEV(); - isInHookUserCodeInDev = true; - } const eagerState = eagerReducer(currentState, action); - if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; - } // Stash the eagerly computed state, and the reducer used to compute // it, on the update object. If the reducer hasn't changed by the // time we enter the render phase, then the eager state can be used @@ -1120,8 +1155,7 @@ function dispatchAction( // Suppress the error. It will throw again in the render phase. } finally { if (__DEV__) { - exitDisallowedContextReadInDEV(); - isInHookUserCodeInDev = false; + ReactCurrentDispatcher.current = prevDispatcher; } } } @@ -1129,3 +1163,432 @@ function dispatchAction( scheduleWork(fiber, expirationTime); } } + +export const ContextOnlyDispatcher: Dispatcher = { + readContext, + + useCallback: throwInvalidHookError, + useContext: throwInvalidHookError, + useEffect: throwInvalidHookError, + useImperativeHandle: throwInvalidHookError, + useLayoutEffect: throwInvalidHookError, + useMemo: throwInvalidHookError, + useReducer: throwInvalidHookError, + useRef: throwInvalidHookError, + useState: throwInvalidHookError, + useDebugValue: throwInvalidHookError, +}; + +const HooksDispatcherOnMount: Dispatcher = { + readContext, + + useCallback: mountCallback, + useContext: readContext, + useEffect: mountEffect, + useImperativeHandle: mountImperativeHandle, + useLayoutEffect: mountLayoutEffect, + useMemo: mountMemo, + useReducer: mountReducer, + useRef: mountRef, + useState: mountState, + useDebugValue: mountDebugValue, +}; + +const HooksDispatcherOnUpdate: Dispatcher = { + readContext, + + useCallback: updateCallback, + useContext: readContext, + useEffect: updateEffect, + useImperativeHandle: updateImperativeHandle, + useLayoutEffect: updateLayoutEffect, + useMemo: updateMemo, + useReducer: updateReducer, + useRef: updateRef, + useState: updateState, + useDebugValue: updateDebugValue, +}; + +let HooksDispatcherOnMountInDEV: Dispatcher | null = null; +let HooksDispatcherOnUpdateInDEV: Dispatcher | null = null; +let InvalidNestedHooksDispatcherOnMountInDEV: Dispatcher | null = null; +let InvalidNestedHooksDispatcherOnUpdateInDEV: Dispatcher | null = null; + +if (__DEV__) { + const warnInvalidContextAccess = () => { + warning( + false, + 'Context can only be read while React is rendering. ' + + 'In classes, you can read it in the render method or getDerivedStateFromProps. ' + + 'In function components, you can read it directly in the function body, but not ' + + 'inside Hooks like useReducer() or useMemo().', + ); + }; + + const warnInvalidHookAccess = () => { + warning( + false, + 'Hooks can only be called inside the body of a function component. ' + + 'Do not call Hooks inside other Hooks. For more information, see ' + + 'https://fb.me/rules-of-hooks', + ); + }; + + HooksDispatcherOnMountInDEV = { + readContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + return readContext(context, observedBits); + }, + + useCallback(callback: T, deps: Array | void | null): T { + currentHookNameInDev = 'useCallback'; + return mountCallback(callback, deps); + }, + useContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + currentHookNameInDev = 'useContext'; + return mountContext(context, observedBits); + }, + useEffect(create: () => mixed, deps: Array | void | null): void { + currentHookNameInDev = 'useEffect'; + return mountEffect(create, deps); + }, + useImperativeHandle( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useImperativeHandle'; + return mountImperativeHandle(ref, create, deps); + }, + useLayoutEffect( + create: () => mixed, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useLayoutEffect'; + return mountLayoutEffect(create, deps); + }, + useMemo(create: () => T, deps: Array | void | null): T { + currentHookNameInDev = 'useMemo'; + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountMemo(create, deps); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, + ): [S, Dispatch] { + currentHookNameInDev = 'useReducer'; + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountReducer(reducer, initialState, initialAction); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useRef(initialValue: T): {current: T} { + currentHookNameInDev = 'useRef'; + return mountRef(initialValue); + }, + useState( + initialState: (() => S) | S, + ): [S, Dispatch>] { + currentHookNameInDev = 'useState'; + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountState(initialState); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { + currentHookNameInDev = 'useDebugValue'; + return mountDebugValue(value, formatterFn); + }, + }; + + HooksDispatcherOnUpdateInDEV = { + readContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + return readContext(context, observedBits); + }, + + useCallback(callback: T, deps: Array | void | null): T { + currentHookNameInDev = 'useCallback'; + return updateCallback(callback, deps); + }, + useContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + currentHookNameInDev = 'useContext'; + return updateContext(context, observedBits); + }, + useEffect(create: () => mixed, deps: Array | void | null): void { + currentHookNameInDev = 'useEffect'; + return updateEffect(create, deps); + }, + useImperativeHandle( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useImperativeHandle'; + return updateImperativeHandle(ref, create, deps); + }, + useLayoutEffect( + create: () => mixed, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useLayoutEffect'; + return updateLayoutEffect(create, deps); + }, + useMemo(create: () => T, deps: Array | void | null): T { + currentHookNameInDev = 'useMemo'; + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateMemo(create, deps); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, + ): [S, Dispatch] { + currentHookNameInDev = 'useReducer'; + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateReducer(reducer, initialState, initialAction); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useRef(initialValue: T): {current: T} { + currentHookNameInDev = 'useRef'; + return updateRef(initialValue); + }, + useState( + initialState: (() => S) | S, + ): [S, Dispatch>] { + currentHookNameInDev = 'useState'; + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateState(initialState); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { + currentHookNameInDev = 'useDebugValue'; + return updateDebugValue(value, formatterFn); + }, + }; + + InvalidNestedHooksDispatcherOnMountInDEV = { + readContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + warnInvalidContextAccess(); + return readContext(context, observedBits); + }, + + useCallback(callback: T, deps: Array | void | null): T { + currentHookNameInDev = 'useCallback'; + warnInvalidHookAccess(); + return mountCallback(callback, deps); + }, + useContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + currentHookNameInDev = 'useContext'; + warnInvalidHookAccess(); + return mountContext(context, observedBits); + }, + useEffect(create: () => mixed, deps: Array | void | null): void { + currentHookNameInDev = 'useEffect'; + warnInvalidHookAccess(); + return mountEffect(create, deps); + }, + useImperativeHandle( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useImperativeHandle'; + warnInvalidHookAccess(); + return mountImperativeHandle(ref, create, deps); + }, + useLayoutEffect( + create: () => mixed, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useLayoutEffect'; + warnInvalidHookAccess(); + return mountLayoutEffect(create, deps); + }, + useMemo(create: () => T, deps: Array | void | null): T { + currentHookNameInDev = 'useMemo'; + warnInvalidHookAccess(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountMemo(create, deps); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, + ): [S, Dispatch] { + currentHookNameInDev = 'useReducer'; + warnInvalidHookAccess(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountReducer(reducer, initialState, initialAction); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useRef(initialValue: T): {current: T} { + currentHookNameInDev = 'useRef'; + warnInvalidHookAccess(); + return mountRef(initialValue); + }, + useState( + initialState: (() => S) | S, + ): [S, Dispatch>] { + currentHookNameInDev = 'useState'; + warnInvalidHookAccess(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV; + try { + return mountState(initialState); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { + currentHookNameInDev = 'useDebugValue'; + warnInvalidHookAccess(); + return mountDebugValue(value, formatterFn); + }, + }; + + InvalidNestedHooksDispatcherOnUpdateInDEV = { + readContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + warnInvalidContextAccess(); + return readContext(context, observedBits); + }, + + useCallback(callback: T, deps: Array | void | null): T { + currentHookNameInDev = 'useCallback'; + warnInvalidHookAccess(); + return updateCallback(callback, deps); + }, + useContext( + context: ReactContext, + observedBits: void | number | boolean, + ): T { + currentHookNameInDev = 'useContext'; + warnInvalidHookAccess(); + return updateContext(context, observedBits); + }, + useEffect(create: () => mixed, deps: Array | void | null): void { + currentHookNameInDev = 'useEffect'; + warnInvalidHookAccess(); + return updateEffect(create, deps); + }, + useImperativeHandle( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useImperativeHandle'; + warnInvalidHookAccess(); + return updateImperativeHandle(ref, create, deps); + }, + useLayoutEffect( + create: () => mixed, + deps: Array | void | null, + ): void { + currentHookNameInDev = 'useLayoutEffect'; + warnInvalidHookAccess(); + return updateLayoutEffect(create, deps); + }, + useMemo(create: () => T, deps: Array | void | null): T { + currentHookNameInDev = 'useMemo'; + warnInvalidHookAccess(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateMemo(create, deps); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, + ): [S, Dispatch] { + currentHookNameInDev = 'useReducer'; + warnInvalidHookAccess(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateReducer(reducer, initialState, initialAction); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useRef(initialValue: T): {current: T} { + currentHookNameInDev = 'useRef'; + warnInvalidHookAccess(); + return updateRef(initialValue); + }, + useState( + initialState: (() => S) | S, + ): [S, Dispatch>] { + currentHookNameInDev = 'useState'; + warnInvalidHookAccess(); + const prevDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV; + try { + return updateState(initialState); + } finally { + ReactCurrentDispatcher.current = prevDispatcher; + } + }, + useDebugValue(value: T, formatterFn: ?(value: T) => mixed): void { + currentHookNameInDev = 'useDebugValue'; + warnInvalidHookAccess(); + return updateDebugValue(value, formatterFn); + }, + }; +} diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 1563f54b69..bb199da856 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -164,7 +164,7 @@ import { commitDetachRef, commitPassiveHookEffects, } from './ReactFiberCommitWork'; -import {Dispatcher} from './ReactFiberDispatcher'; +import {ContextOnlyDispatcher} from './ReactFiberHooks'; export type Thenable = { then(resolve: () => mixed, reject?: () => mixed): mixed, @@ -1216,7 +1216,8 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void { flushPassiveEffects(); isWorking = true; - ReactCurrentDispatcher.current = Dispatcher; + const previousDispatcher = ReactCurrentDispatcher.current; + ReactCurrentDispatcher.current = ContextOnlyDispatcher; const expirationTime = root.nextExpirationTimeToWorkOn; @@ -1377,7 +1378,7 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void { // We're done performing work. Time to clean up. isWorking = false; - ReactCurrentDispatcher.current = null; + ReactCurrentDispatcher.current = previousDispatcher; resetContextDependences(); resetHooks(); diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 862d8fbe2b..0a37db0d37 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -719,7 +719,7 @@ describe('ReactHooks', () => { const root = ReactTestRenderer.create(); expect(() => root.update()).toThrow( // The exact message doesn't matter, just make sure we don't allow this - "Cannot read property 'readContext' of null", + 'Context can only be read while React is rendering', ); }); @@ -740,7 +740,7 @@ describe('ReactHooks', () => { expect(() => ReactTestRenderer.create()).toThrow( // The exact message doesn't matter, just make sure we don't allow this - "Cannot read property 'readContext' of null", + 'Context can only be read while React is rendering', ); }); @@ -803,7 +803,10 @@ describe('ReactHooks', () => { }); it('warns when calling hooks inside useReducer', () => { - const {useReducer, useRef} = React; + const {useReducer, useState, useRef} = React; + + spyOnDev(console, 'error'); + function App() { const [value, dispatch] = useReducer((state, action) => { useRef(0); @@ -812,11 +815,19 @@ describe('ReactHooks', () => { if (value === 0) { dispatch('foo'); } + useState(); return value; } - expect(() => ReactTestRenderer.create()).toWarnDev( - 'Hooks can only be called inside the body of a function component', - ); + expect(() => { + ReactTestRenderer.create(); + }).toThrow('Rendered more hooks than during the previous render.'); + + if (__DEV__) { + expect(console.error).toHaveBeenCalledTimes(3); + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Hooks can only be called inside the body of a function component', + ); + } }); it("throws when calling hooks inside useState's initialize function", () => { diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index fe67d1ab62..363ef3bd42 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -1596,11 +1596,11 @@ describe('ReactHooksWithNoopRenderer', () => { }); }); - describe('progressive enhancement', () => { + describe('progressive enhancement (not supported)', () => { it('mount additional state', () => { let updateA; let updateB; - let updateC; + // let updateC; function App(props) { const [A, _updateA] = useState(0); @@ -1610,9 +1610,7 @@ describe('ReactHooksWithNoopRenderer', () => { let C; if (props.loadC) { - const [_C, _updateC] = useState(0); - C = _C; - updateC = _updateC; + useState(0); } else { C = '[not loaded]'; } @@ -1636,14 +1634,14 @@ describe('ReactHooksWithNoopRenderer', () => { ReactNoop.render(); expect(() => { expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 0']); - }).toWarnDev([ - 'App: Rendered more hooks than during the previous render', - ]); - expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); + }).toThrow('Rendered more hooks than during the previous render'); - updateC(4); - expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']); - expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + // Uncomment if/when we support this again + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); + + // updateC(4); + // expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']); + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); }); it('unmount state', () => { @@ -1714,17 +1712,17 @@ describe('ReactHooksWithNoopRenderer', () => { ReactNoop.render(); expect(() => { expect(ReactNoop.flush()).toEqual([]); - }).toWarnDev([ - 'App: Rendered more hooks than during the previous render', - ]); - flushPassiveEffects(); - expect(ReactNoop.clearYields()).toEqual(['Mount B']); + }).toThrow('Rendered more hooks than during the previous render'); - ReactNoop.render(); - expect(() => ReactNoop.flush()).toThrow( - 'Rendered fewer hooks than expected. This may be caused by an ' + - 'accidental early return statement.', - ); + // Uncomment if/when we support this again + // flushPassiveEffects(); + // expect(ReactNoop.clearYields()).toEqual(['Mount B']); + + // ReactNoop.render(); + // expect(() => ReactNoop.flush()).toThrow( + // 'Rendered fewer hooks than expected. This may be caused by an ' + + // 'accidental early return statement.', + // ); }); }); }); diff --git a/packages/react-test-renderer/src/ReactShallowRenderer.js b/packages/react-test-renderer/src/ReactShallowRenderer.js index 815220efb7..ee8b68b0f8 100644 --- a/packages/react-test-renderer/src/ReactShallowRenderer.js +++ b/packages/react-test-renderer/src/ReactShallowRenderer.js @@ -18,7 +18,7 @@ import ReactSharedInternals from 'shared/ReactSharedInternals'; import warning from 'shared/warning'; import is from 'shared/objectIs'; -import typeof {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberDispatcher'; +import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactFiberHooks'; import type {ReactContext} from 'shared/ReactTypes'; import type {ReactElement} from 'shared/ReactElementType'; diff --git a/packages/react/src/ReactCurrentDispatcher.js b/packages/react/src/ReactCurrentDispatcher.js index 089082d8d8..5b08f8502c 100644 --- a/packages/react/src/ReactCurrentDispatcher.js +++ b/packages/react/src/ReactCurrentDispatcher.js @@ -7,7 +7,7 @@ * @flow */ -import typeof {Dispatcher} from 'react-reconciler/src/ReactFiberDispatcher'; +import type {Dispatcher} from 'react-reconciler/src/ReactFiberHooks'; /** * Keeps track of the current dispatcher.