From 7bee9fbdd49aa5b9365a94b0ddf6db04bc1bf51c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 5 Sep 2018 11:29:08 -0700 Subject: [PATCH] Initial hooks implementation Includes: - useState - useContext - useEffect - useRef - useReducer - useCallback - useMemo - useAPI --- .../src/ReactFiberBeginWork.js | 31 +- .../src/ReactFiberCommitWork.js | 170 +++ .../src/ReactFiberDispatcher.js | 16 + .../react-reconciler/src/ReactFiberHooks.js | 691 +++++++++++ .../src/ReactFiberScheduler.js | 5 + .../src/__tests__/ReactHooks-test.internal.js | 1064 +++++++++++++++++ .../ReactNewContext-test.internal.js | 27 +- packages/react/src/React.js | 19 + packages/react/src/ReactHooks.js | 83 ++ 9 files changed, 2075 insertions(+), 31 deletions(-) create mode 100644 packages/react-reconciler/src/ReactFiberHooks.js create mode 100644 packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js create mode 100644 packages/react/src/ReactHooks.js diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 5358a6931002..051729a0d832 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -79,6 +79,7 @@ import { prepareToReadContext, calculateChangedBits, } from './ReactFiberNewContext'; +import {prepareToUseHooks, finishHooks, resetHooks} from './ReactFiberHooks'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; import { getMaskedContext, @@ -193,27 +194,17 @@ function forceUnmountCurrentAndReconcile( function updateForwardRef( current: Fiber | null, workInProgress: Fiber, - type: any, + Component: any, nextProps: any, renderExpirationTime: ExpirationTime, ) { - const render = type.render; + const render = Component.render; const ref = workInProgress.ref; - if (hasLegacyContextChanged()) { - // Normally we can bail out on props equality but if context has changed - // we don't do the bailout and we have to reuse existing props instead. - } else if (workInProgress.memoizedProps === nextProps) { - const currentRef = current !== null ? current.ref : null; - if (ref === currentRef) { - return bailoutOnAlreadyFinishedWork( - current, - workInProgress, - renderExpirationTime, - ); - } - } + // The rest is a fork of updateFunctionComponent let nextChildren; + prepareToReadContext(workInProgress, renderExpirationTime); + prepareToUseHooks(current, workInProgress, renderExpirationTime); if (__DEV__) { ReactCurrentOwner.current = workInProgress; ReactCurrentFiber.setCurrentPhase('render'); @@ -222,7 +213,10 @@ function updateForwardRef( } else { nextChildren = render(nextProps, ref); } + nextChildren = finishHooks(render, nextProps, nextChildren, ref); + // React DevTools reads this flag. + workInProgress.effectTag |= PerformedWork; reconcileChildren( current, workInProgress, @@ -406,6 +400,7 @@ function updateFunctionComponent( let nextChildren; prepareToReadContext(workInProgress, renderExpirationTime); + prepareToUseHooks(current, workInProgress, renderExpirationTime); if (__DEV__) { ReactCurrentOwner.current = workInProgress; ReactCurrentFiber.setCurrentPhase('render'); @@ -414,6 +409,7 @@ function updateFunctionComponent( } else { nextChildren = Component(nextProps, context); } + nextChildren = finishHooks(Component, nextProps, nextChildren, context); // React DevTools reads this flag. workInProgress.effectTag |= PerformedWork; @@ -921,6 +917,7 @@ function mountIndeterminateComponent( const context = getMaskedContext(workInProgress, unmaskedContext); prepareToReadContext(workInProgress, renderExpirationTime); + prepareToUseHooks(null, workInProgress, renderExpirationTime); let value; @@ -964,6 +961,9 @@ function mountIndeterminateComponent( // Proceed under the assumption that this is a class instance workInProgress.tag = ClassComponent; + // Throw out any hooks that were used. + resetHooks(); + // Push context providers early to prevent context stack mismatches. // During mounting we don't know the child context yet as the instance doesn't exist. // We will invalidate the child context in finishClassComponent() right after rendering. @@ -1001,6 +1001,7 @@ function mountIndeterminateComponent( } else { // Proceed under the assumption that this is a function component workInProgress.tag = FunctionComponent; + value = finishHooks(Component, props, value, context); if (__DEV__) { if (Component) { warningWithoutStack( diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 60662d2388f3..c8f430ec6c4b 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -19,12 +19,15 @@ import type {FiberRoot} from './ReactFiberRoot'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {CapturedValue, CapturedError} from './ReactCapturedValue'; import type {SuspenseState} from './ReactFiberSuspenseComponent'; +import type {FunctionComponentUpdateQueue} from './ReactFiberHooks'; import { enableSchedulerTracing, enableProfilerTimer, } from 'shared/ReactFeatureFlags'; import { + FunctionComponent, + ForwardRef, ClassComponent, HostRoot, HostComponent, @@ -180,6 +183,22 @@ function safelyDetachRef(current: Fiber) { } } +function safelyCallDestroy(current, destroy) { + if (__DEV__) { + invokeGuardedCallback(null, destroy, null); + if (hasCaughtError()) { + const error = clearCaughtError(); + captureCommitPhaseError(current, error); + } + } else { + try { + destroy(); + } catch (error) { + captureCommitPhaseError(current, error); + } + } +} + function commitBeforeMutationLifeCycles( current: Fiber | null, finishedWork: Fiber, @@ -235,6 +254,28 @@ function commitBeforeMutationLifeCycles( } } +function destroyRemainingEffects(firstToDestroy, stopAt) { + let effect = firstToDestroy; + do { + const destroy = effect.value; + if (destroy !== null) { + destroy(); + } + effect = effect.next; + } while (effect !== stopAt); +} + +function destroyMountedEffects(current) { + const oldUpdateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any); + if (oldUpdateQueue !== null) { + const oldLastEffect = oldUpdateQueue.lastEffect; + if (oldLastEffect !== null) { + const oldFirstEffect = oldLastEffect.next; + destroyRemainingEffects(oldFirstEffect, oldFirstEffect); + } + } +} + function commitLifeCycles( finishedRoot: FiberRoot, current: Fiber | null, @@ -242,6 +283,116 @@ function commitLifeCycles( committedExpirationTime: ExpirationTime, ): void { switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: { + const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); + if (updateQueue !== null) { + // Mount new effects and destroy the old ones by comparing to the + // current list of effects. This could be a bit simpler if we avoided + // the need to compare to the previous effect list by transferring the + // old `destroy` method to the new effect during the render phase. + // That's how I originally implemented it, but it requires an additional + // field on the effect object. + // + // This supports removing effects from the end of the list. If we adopt + // the constraint that hooks are append only, that would also save a bit + // on code size. + const newLastEffect = updateQueue.lastEffect; + if (newLastEffect !== null) { + const newFirstEffect = newLastEffect.next; + let oldLastEffect = null; + if (current !== null) { + const oldUpdateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any); + if (oldUpdateQueue !== null) { + oldLastEffect = oldUpdateQueue.lastEffect; + } + } + if (oldLastEffect !== null) { + const oldFirstEffect = oldLastEffect.next; + let newEffect = newFirstEffect; + let oldEffect = oldFirstEffect; + + // Before mounting the new effects, unmount all the old ones. + do { + if (oldEffect !== null) { + if (newEffect.inputs !== oldEffect.inputs) { + const destroy = oldEffect.value; + if (destroy !== null) { + destroy(); + } + } + oldEffect = oldEffect.next; + if (oldEffect === oldFirstEffect) { + oldEffect = null; + } + } + newEffect = newEffect.next; + } while (newEffect !== newFirstEffect); + + // Unmount any remaining effects in the old list that do not + // appear in the new one. + if (oldEffect !== null) { + destroyRemainingEffects(oldEffect, oldFirstEffect); + } + + // Now loop through the list again to mount the new effects + oldEffect = oldFirstEffect; + do { + const create = newEffect.value; + if (oldEffect !== null) { + if (newEffect.inputs !== oldEffect.inputs) { + const newDestroy = create(); + newEffect.value = + typeof newDestroy === 'function' ? newDestroy : null; + } else { + newEffect.value = oldEffect.value; + } + oldEffect = oldEffect.next; + if (oldEffect === oldFirstEffect) { + oldEffect = null; + } + } else { + const newDestroy = create(); + newEffect.value = + typeof newDestroy === 'function' ? newDestroy : null; + } + newEffect = newEffect.next; + } while (newEffect !== newFirstEffect); + } else { + let newEffect = newFirstEffect; + do { + const create = newEffect.value; + const newDestroy = create(); + newEffect.value = + typeof newDestroy === 'function' ? newDestroy : null; + newEffect = newEffect.next; + } while (newEffect !== newFirstEffect); + } + } else if (current !== null) { + // There are no effects, which means all current effects must + // be destroyed + destroyMountedEffects(current); + } + + const callbackList = updateQueue.callbackList; + if (callbackList !== null) { + updateQueue.callbackList = null; + for (let i = 0; i < callbackList.length; i++) { + const update = callbackList[i]; + // Assume this is non-null, since otherwise it would not be part + // of the callback list. + const callback: () => mixed = (update.callback: any); + update.callback = null; + callback(); + } + } + } else if (current !== null) { + // There are no effects, which means all current effects must + // be destroyed + destroyMountedEffects(current); + } + break; + } case ClassComponent: { const instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { @@ -496,6 +647,25 @@ function commitUnmount(current: Fiber): void { onCommitUnmount(current); switch (current.tag) { + case FunctionComponent: + case ForwardRef: { + const updateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any); + if (updateQueue !== null) { + const lastEffect = updateQueue.lastEffect; + if (lastEffect !== null) { + const firstEffect = lastEffect.next; + let effect = firstEffect; + do { + const destroy = effect.value; + if (destroy !== null) { + safelyCallDestroy(current, destroy); + } + effect = effect.next; + } while (effect !== firstEffect); + } + } + break; + } case ClassComponent: { safelyDetachRef(current); const instance = current.stateNode; diff --git a/packages/react-reconciler/src/ReactFiberDispatcher.js b/packages/react-reconciler/src/ReactFiberDispatcher.js index 354539dd79ef..9c5e790fe440 100644 --- a/packages/react-reconciler/src/ReactFiberDispatcher.js +++ b/packages/react-reconciler/src/ReactFiberDispatcher.js @@ -8,7 +8,23 @@ */ import {readContext} from './ReactFiberNewContext'; +import { + useState, + useReducer, + useEffect, + useCallback, + useMemo, + useRef, + useAPI, +} from './ReactFiberHooks'; export const Dispatcher = { readContext, + useState, + useReducer, + useEffect, + useCallback, + useMemo, + useRef, + useAPI, }; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js new file mode 100644 index 000000000000..66720a3707ef --- /dev/null +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -0,0 +1,691 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root direcreatey of this source tree. + * + * @flow + */ + +import type {Fiber} from './ReactFiber'; +import type {ExpirationTime} from './ReactFiberExpirationTime'; + +import {NoWork} from './ReactFiberExpirationTime'; +import {Callback as CallbackEffect} from 'shared/ReactSideEffectTags'; +import { + scheduleWork, + computeExpirationForFiber, + requestCurrentTime, +} from './ReactFiberScheduler'; + +import invariant from 'shared/invariant'; + +type Update = { + expirationTime: ExpirationTime, + action: A, + callback: null | (S => mixed), + next: Update | null, +}; + +type UpdateQueue = { + last: Update | null, + dispatch: any, +}; + +type Hook = { + memoizedState: any, + + baseState: any, + baseUpdate: Update | null, + queue: UpdateQueue | null, + + next: Hook | null, +}; + +type Effect = { + // For an unmounted effect, this points to the effect constructor. Once it's + // mounted, it points to a destroy function (or null). I've opted to reuse + // the same field to save memory. + value: any, + inputs: Array, + next: Effect, +}; + +export type FunctionComponentUpdateQueue = { + callbackList: Array> | null, + lastEffect: Effect | null, +}; + +type BasicStateAction = S | (S => S); + +type MaybeCallback = void | null | (S => mixed); + +type Dispatch = (A, MaybeCallback) => void; + +// These are set right before calling the component. +let renderExpirationTime: ExpirationTime = NoWork; +// The work-in-progress fiber. I've named it differently to distinguish it from +// the work-in-progress hook. +let currentlyRenderingFiber: Fiber | null = null; + +// Hooks are stored as a linked list on the fiber's memoizedState field. The +// current hook list is the list that belongs to the current fiber. The +// work-in-progress hook list is a new list that will be added to the +// work-in-progress fiber. +let firstCurrentHook: Hook | null = null; +let currentHook: Hook | null = null; +let firstWorkInProgressHook: Hook | null = null; +let workInProgressHook: Hook | null = null; + +let remainingExpirationTime: ExpirationTime = NoWork; +let componentUpdateQueue: FunctionComponentUpdateQueue | null = null; + +// 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, +// because if the work is aborted, they should be discarded. Because this is +// a relatively rare case, we also don't want to add an additional field to +// either the hook or queue object types. So we store them in a lazily create +// map of queue -> render-phase updates, which are discarded once the component +// completes without re-rendering. + +// Whether the work-in-progress hook is a re-rendered hook +let isReRender: boolean = false; +// Whether an update was scheduled during the currently executing render pass. +let didScheduleRenderPhaseUpdate: boolean = false; +// Lazily created map of render-phase updates +let renderPhaseUpdates: Map< + UpdateQueue, + Update, +> | null = null; +// Counter to prevent infinite loops. +let numberOfReRenders: number = 0; +const RE_RENDER_LIMIT = 25; + +function resolveCurrentlyRenderingFiber(): Fiber { + invariant( + currentlyRenderingFiber !== null, + 'Hooks can only be called inside the body of a functional component.', + ); + return currentlyRenderingFiber; +} + +export function prepareToUseHooks( + current: Fiber | null, + workInProgress: Fiber, + nextRenderExpirationTime: ExpirationTime, +): void { + renderExpirationTime = nextRenderExpirationTime; + currentlyRenderingFiber = workInProgress; + firstCurrentHook = current !== null ? current.memoizedState : null; + + // The following should have already been reset + // currentHook = null; + // workInProgressHook = null; + + // remainingExpirationTime = NoWork; + // componentUpdateQueue = null; + + // isReRender = false; + // didScheduleRenderPhaseUpdate = false; + // renderPhaseUpdates = null; + // numberOfReRenders = 0; +} + +export function finishHooks( + Component: any, + props: any, + children: any, + refOrContext: any, +): any { + // This must be called after every functional component to prevent hooks from + // being used in classes. + + while (didScheduleRenderPhaseUpdate) { + // Updates were scheduled during the render phase. They are stored in + // the `renderPhaseUpdates` map. Call the component again, reusing the + // work-in-progress hooks and applying the additional updates on top. Keep + // restarting until no more updates are scheduled. + didScheduleRenderPhaseUpdate = false; + numberOfReRenders += 1; + + // Start over from the beginning of the list + currentHook = null; + workInProgressHook = null; + componentUpdateQueue = null; + + children = Component(props, refOrContext); + } + renderPhaseUpdates = null; + numberOfReRenders = 0; + + const renderedWork: Fiber = (currentlyRenderingFiber: any); + + renderedWork.memoizedState = firstWorkInProgressHook; + renderedWork.expirationTime = remainingExpirationTime; + if (componentUpdateQueue !== null) { + renderedWork.updateQueue = (componentUpdateQueue: any); + } + + renderExpirationTime = NoWork; + currentlyRenderingFiber = null; + + firstCurrentHook = null; + currentHook = null; + firstWorkInProgressHook = null; + workInProgressHook = null; + + remainingExpirationTime = NoWork; + componentUpdateQueue = null; + + // Always set during createWorkInProgress + // isReRender = false; + + // These were reset above + // didScheduleRenderPhaseUpdate = false; + // renderPhaseUpdates = null; + // numberOfReRenders = 0; + + return children; +} + +export function resetHooks(): void { + // This is called instead of `finishHooks` if the component throws. It's also + // called inside mountIndeterminateComponent if we determine the component + // is a module-style component. + renderExpirationTime = NoWork; + currentlyRenderingFiber = null; + + firstCurrentHook = null; + currentHook = null; + firstWorkInProgressHook = null; + workInProgressHook = null; + + remainingExpirationTime = NoWork; + componentUpdateQueue = null; + + // Always set during createWorkInProgress + // isReRender = false; + + didScheduleRenderPhaseUpdate = false; + renderPhaseUpdates = null; + numberOfReRenders = 0; +} + +function createHook(): Hook { + return { + memoizedState: null, + + baseState: null, + queue: null, + baseUpdate: null, + + next: null, + }; +} + +function cloneHook(hook: Hook): Hook { + return { + memoizedState: hook.memoizedState, + + baseState: hook.memoizedState, + queue: hook.queue, + baseUpdate: hook.baseUpdate, + + next: null, + }; +} + +function createWorkInProgressHook(): Hook { + if (workInProgressHook === null) { + // This is the first hook in the list + if (firstWorkInProgressHook === null) { + isReRender = false; + 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. + isReRender = true; + currentHook = firstCurrentHook; + workInProgressHook = firstWorkInProgressHook; + } + } else { + if (workInProgressHook.next === null) { + isReRender = false; + 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; + } else { + // There's already a work-in-progress. Reuse it. + isReRender = true; + workInProgressHook = workInProgressHook.next; + currentHook = currentHook !== null ? currentHook.next : null; + } + } + return workInProgressHook; +} + +function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { + return { + callbackList: null, + lastEffect: null, + }; +} + +function basicStateReducer(state: S, action: BasicStateAction): S { + return typeof action === 'function' ? action(state) : action; +} + +export function useState( + initialState: S | (() => S), +): [S, Dispatch>] { + return useReducer( + basicStateReducer, + // useReducer has a special case to support lazy useState initializers + (initialState: any), + ); +} + +export function useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, +): [S, Dispatch] { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + if (isReRender) { + // This is a re-render. Apply the new render phase updates to the previous + // work-in-progress hook. + const queue: UpdateQueue = (workInProgressHook.queue: any); + 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; + newState = reducer(newState, action); + const callback = update.callback; + if (callback !== null) { + pushCallback(currentlyRenderingFiber, update); + } + 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]; + } else if (currentHook !== null) { + const queue: UpdateQueue = (workInProgressHook.queue: any); + + // 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; + + // 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 = workInProgressHook.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 ( + remainingExpirationTime === NoWork || + updateExpirationTime < remainingExpirationTime + ) { + remainingExpirationTime = updateExpirationTime; + } + } else { + // Process this update. + const action = update.action; + newState = reducer(newState, action); + const callback = update.callback; + if (callback !== null) { + pushCallback(currentlyRenderingFiber, update); + } + } + 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; + } + + const dispatch: Dispatch = (queue.dispatch: any); + return [workInProgressHook.memoizedState, dispatch]; + } else { + if (reducer === basicStateReducer) { + // Special case for `useState`. + if (typeof initialState === 'function') { + initialState = initialState(); + } + } else if (initialAction !== undefined && initialAction !== null) { + initialState = reducer(initialState, initialAction); + } + workInProgressHook.memoizedState = workInProgressHook.baseState = initialState; + const queue: UpdateQueue = (workInProgressHook.queue = { + last: null, + dispatch: null, + }); + const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( + null, + currentlyRenderingFiber, + queue, + ): any)); + return [workInProgressHook.memoizedState, dispatch]; + } +} + +function pushCallback(workInProgress: Fiber, update: Update): void { + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + componentUpdateQueue.callbackList = [update]; + } else { + const callbackList = componentUpdateQueue.callbackList; + if (callbackList === null) { + componentUpdateQueue.callbackList = [update]; + } else { + callbackList.push(update); + } + } + workInProgress.effectTag |= CallbackEffect; +} + +function pushEffect(value, inputs) { + const effect: Effect = { + value, + inputs, + // Circular + next: (null: any), + }; + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + componentUpdateQueue.lastEffect = effect.next = effect; + } else { + const lastEffect = componentUpdateQueue.lastEffect; + if (lastEffect === null) { + componentUpdateQueue.lastEffect = effect.next = effect; + } else { + const firstEffect = lastEffect.next; + lastEffect.next = effect; + effect.next = firstEffect; + componentUpdateQueue.lastEffect = effect; + } + } + return effect; +} + +export function useRef(initialValue: T): {current: T} { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + let ref; + if (currentHook === null) { + ref = {current: initialValue}; + if (__DEV__) { + Object.seal(ref); + } + workInProgressHook.memoizedState = ref; + } else { + ref = workInProgressHook.memoizedState; + } + return ref; +} + +export function useEffect( + create: () => mixed, + inputs: Array | void | null, +): void { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + + let nextEffect; + let nextInputs = inputs !== undefined && inputs !== null ? inputs : [create]; + if (currentHook !== null) { + const prevEffect = currentHook.memoizedState; + const prevInputs = prevEffect.inputs; + if (inputsAreEqual(nextInputs, prevInputs)) { + nextEffect = pushEffect(prevEffect.value, prevInputs); + } else { + nextEffect = pushEffect(create, nextInputs); + } + } else { + nextEffect = pushEffect(create, nextInputs); + } + + // TODO: If we decide not to support removing hooks from the end of the list, + // we only need to schedule an effect if the inputs changed. + currentlyRenderingFiber.effectTag |= CallbackEffect; + workInProgressHook.memoizedState = nextEffect; +} + +export function useAPI( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + inputs: Array | void | null, +): void { + // TODO: If inputs are provided, should we skip comparing the ref itself? + const nextInputs = + inputs !== null && inputs !== undefined + ? inputs.concat([ref]) + : [ref, create]; + + // 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. + useEffect(() => { + if (typeof ref === 'function') { + const refCallback = ref; + const inst = create(); + refCallback(inst); + return () => refCallback(null); + } else if (ref !== null && ref !== undefined) { + const refObject = ref; + const inst = create(); + refObject.current = inst; + return () => { + refObject.current = null; + }; + } + }, nextInputs); +} + +export function useCallback( + callback: T, + inputs: Array | void | null, +): T { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + + const nextInputs = + inputs !== undefined && inputs !== null ? inputs : [callback]; + + if (currentHook !== null) { + const prevState = currentHook.memoizedState; + const prevInputs = prevState[1]; + if (inputsAreEqual(nextInputs, prevInputs)) { + return prevState[0]; + } + } + + workInProgressHook.memoizedState = [callback, nextInputs]; + return callback; +} + +export function useMemo( + nextCreate: () => T, + inputs: Array | void | null, +): T { + currentlyRenderingFiber = resolveCurrentlyRenderingFiber(); + workInProgressHook = createWorkInProgressHook(); + + const nextInputs = + inputs !== undefined && inputs !== null ? inputs : [nextCreate]; + + if (currentHook !== null) { + const prevState = currentHook.memoizedState; + const prevInputs = prevState[1]; + if (inputsAreEqual(nextInputs, prevInputs)) { + return prevState[0]; + } + } + + const nextValue = nextCreate(); + workInProgressHook.memoizedState = [nextValue, nextInputs]; + return nextValue; +} + +function dispatchAction( + fiber: Fiber, + queue: UpdateQueue, + action: A, + callback: void | null | (S => mixed), +) { + invariant( + numberOfReRenders < RE_RENDER_LIMIT, + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + + const alternate = fiber.alternate; + if ( + fiber === currentlyRenderingFiber || + (alternate !== null && alternate === currentlyRenderingFiber) + ) { + // This is a render phase update. Stash it in a lazily-created map of + // queue -> linked list of updates. After this render pass, we'll restart + // and apply the stashed updates on top of the work-in-progress hook. + didScheduleRenderPhaseUpdate = true; + const update: Update = { + expirationTime: renderExpirationTime, + action, + callback: callback !== undefined ? callback : null, + next: null, + }; + if (renderPhaseUpdates === null) { + renderPhaseUpdates = new Map(); + } + const firstRenderPhaseUpdate = renderPhaseUpdates.get(queue); + if (firstRenderPhaseUpdate === undefined) { + renderPhaseUpdates.set(queue, update); + } else { + // Append the update to the end of the list. + let lastRenderPhaseUpdate = firstRenderPhaseUpdate; + while (lastRenderPhaseUpdate.next !== null) { + lastRenderPhaseUpdate = lastRenderPhaseUpdate.next; + } + lastRenderPhaseUpdate.next = update; + } + } else { + const currentTime = requestCurrentTime(); + const expirationTime = computeExpirationForFiber(currentTime, fiber); + const update: Update = { + expirationTime, + action, + callback: callback !== undefined ? callback : null, + next: null, + }; + // Append the update to the end of the list. + const last = queue.last; + if (last === null) { + // This is the first update. Create a circular list. + update.next = update; + } else { + const first = last.next; + if (first !== null) { + // Still circular. + update.next = first; + } + last.next = update; + } + queue.last = update; + scheduleWork(fiber, expirationTime); + } +} + +function inputsAreEqual(arr1, arr2) { + // Don't bother comparing lengths because these arrays are always + // passed inline. + for (let i = 0; i < arr1.length; i++) { + // Inlined Object.is polyfill. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + const val1 = arr1[i]; + const val2 = arr2[i]; + if ( + (val1 === val2 && (val1 !== 0 || 1 / val1 === 1 / (val2: any))) || + (val1 !== val1 && val2 !== val2) // eslint-disable-line no-self-compare + ) { + continue; + } + return false; + } + return true; +} diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 34be16bdb252..11f2c7a1b6c9 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -122,6 +122,7 @@ import { popContext as popLegacyContext, } from './ReactFiberContext'; import {popProvider, resetContextDependences} from './ReactFiberNewContext'; +import {resetHooks} from './ReactFiberHooks'; import {popHostContext, popHostContainer} from './ReactFiberHostContext'; import { recordCommitTime, @@ -1222,6 +1223,9 @@ function renderRoot( try { workLoop(isYieldy); } catch (thrownValue) { + resetContextDependences(); + resetHooks(); + if (nextUnitOfWork === null) { // This is a fatal error. didFatal = true; @@ -1284,6 +1288,7 @@ function renderRoot( isWorking = false; ReactCurrentOwner.currentDispatcher = null; resetContextDependences(); + resetHooks(); // Yield back to main thread. if (didFatal) { diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js new file mode 100644 index 000000000000..5bcf44621402 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -0,0 +1,1064 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment node + */ + +/* eslint-disable no-func-assign */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactNoop; +let useState; +let useReducer; +let useEffect; +let useCallback; +let useMemo; +let useRef; +let useAPI; +let forwardRef; + +describe('ReactHooks', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + useState = React.useState; + useReducer = React.useReducer; + useEffect = React.useEffect; + useCallback = React.useCallback; + useMemo = React.useMemo; + useRef = React.useRef; + useAPI = React.useAPI; + forwardRef = React.forwardRef; + }); + + function span(prop) { + return {type: 'span', hidden: false, children: [], prop}; + } + + function Text(props) { + ReactNoop.yield(props.text); + return ; + } + + it('resumes after an interruption', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useAPI(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + + // Initial mount + const counter = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Schedule some updates + counter.current.updateCount(1); + counter.current.updateCount(count => count + 10); + // Partially flush without committing + ReactNoop.flushThrough(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + // Interrupt with a high priority update + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(ReactNoop.clearYields()).toEqual(['Total: 0']); + + // Resume rendering + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); + }); + + it('throws inside class components', () => { + class BadCounter extends React.Component { + render() { + const [count] = useState(0); + return ; + } + } + ReactNoop.render(); + + expect(() => ReactNoop.flush()).toThrow( + 'Hooks can only be called inside the body of a functional component.', + ); + + // Confirm that a subsequent hook works properly. + function GoodCounter(props, ref) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([10]); + }); + + it('throws inside module-style components', () => { + function Counter() { + return { + render() { + const [count] = useState(0); + return ; + }, + }; + } + ReactNoop.render(); + expect(() => ReactNoop.flush()).toThrow( + 'Hooks can only be called inside the body of a functional component.', + ); + + // Confirm that a subsequent hook works properly. + function GoodCounter(props) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([10]); + }); + + it('throws when called outside the render phase', () => { + expect(() => useState(0)).toThrow( + 'Hooks can only be called inside the body of a functional component.', + ); + }); + + describe('useState', () => { + it('simple mount and update', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useAPI(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.updateCount(1); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + counter.current.updateCount(count => count + 10); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + }); + + it('lazy state initializer', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(() => { + ReactNoop.yield('getInitialState'); + return props.initialState; + }); + useAPI(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['getInitialState', 'Count: 42']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]); + + counter.current.updateCount(7); + expect(ReactNoop.flush()).toEqual(['Count: 7']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); + }); + + it('multiple states', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + const [label, updateLabel] = useState('Count'); + useAPI(ref, () => ({updateCount, updateLabel})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.updateCount(7); + expect(ReactNoop.flush()).toEqual(['Count: 7']); + + counter.current.updateLabel('Total'); + expect(ReactNoop.flush()).toEqual(['Total: 7']); + }); + + it('callbacks', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useAPI(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.updateCount(7, count => { + ReactNoop.yield(`Did update count`); + }); + expect(ReactNoop.flush()).toEqual(['Count: 7', 'Did update count']); + + // Update twice in the same batch + counter.current.updateCount(1, () => { + ReactNoop.yield(`Did update count (first callback)`); + }); + counter.current.updateCount(2, () => { + ReactNoop.yield(`Did update count (second callback)`); + }); + expect(ReactNoop.flush()).toEqual([ + // Component only renders once + 'Count: 2', + 'Did update count (first callback)', + 'Did update count (second callback)', + ]); + }); + + it('does not fire callbacks more than once when rebasing', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useAPI(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.updateCount(1, count => { + ReactNoop.yield(`Did update count (low pri)`); + }); + ReactNoop.flushSync(() => { + counter.current.updateCount(2, count => { + ReactNoop.yield(`Did update count (high pri)`); + }); + }); + expect(ReactNoop.clearYields()).toEqual([ + 'Count: 2', + 'Did update count (high pri)', + ]); + // The high-pri update is processed again when we render at low priority, + // but its callback should not fire again. + expect(ReactNoop.flush()).toEqual([ + 'Count: 2', + 'Did update count (low pri)', + ]); + }); + + it('returns the same updater function every time', () => { + let updaters = []; + function Counter() { + const [count, updateCount] = useState(0); + updaters.push(updateCount); + return ; + } + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + updaters[0](1); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + updaters[0](count => count + 10); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + + expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); + }); + }); + + describe('updates during the render phase', () => { + it('restarts the render function and applies the new updates on top', () => { + function ScrollView({row: newRow}) { + let [isScrollingDown, setIsScrollingDown] = useState(false); + let [row, setRow] = useState(null); + + if (row !== newRow) { + // Row changed since last render. Update isScrollingDown. + setIsScrollingDown(row !== null && newRow > row); + setRow(newRow); + } + + return ; + } + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + }); + + it('keeps restarting until there are no more new updates', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + ReactNoop.yield('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('updates multiple times within same render function', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 12) { + setCount(c => c + 1); + setCount(c => c + 1); + setCount(c => c + 1); + } + ReactNoop.yield('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // Should increase by three each time + 'Render: 0', + 'Render: 3', + 'Render: 6', + 'Render: 9', + 'Render: 12', + 12, + ]); + expect(ReactNoop.getChildren()).toEqual([span(12)]); + }); + + it('throws after too many iterations', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + setCount(count + 1); + ReactNoop.yield('Render: ' + count); + return ; + } + ReactNoop.render(); + expect(() => ReactNoop.flush()).toThrow( + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + }); + + it('works with useReducer', () => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter({row: newRow}) { + let [count, dispatch] = useReducer(reducer, 0); + if (count < 3) { + dispatch('increment'); + } + ReactNoop.yield('Render: ' + count); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('uses reducer passed at time of render, not time of dispatch', () => { + // This test is a bit contrived but it demonstrates a subtle edge case. + + // Reducer A increments by 1. Reducer B increments by 10. + function reducerA(state, action) { + switch (action) { + case 'increment': + return state + 1; + case 'reset': + return 0; + } + } + function reducerB(state, action) { + switch (action) { + case 'increment': + return state + 10; + case 'reset': + return 0; + } + } + + function Counter({row: newRow}, ref) { + let [reducer, setReducer] = useState(() => reducerA); + let [count, dispatch] = useReducer(reducer, 0); + useAPI(ref, () => ({dispatch})); + if (count < 20) { + dispatch('increment'); + // Swap reducers each time we increment + if (reducer === reducerA) { + setReducer(() => reducerB); + } else { + setReducer(() => reducerA); + } + } + ReactNoop.yield('Render: ' + count); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // The count should increase by alternating amounts of 10 and 1 + // until we reach 21. + 'Render: 0', + 'Render: 10', + 'Render: 11', + 'Render: 21', + 21, + ]); + expect(ReactNoop.getChildren()).toEqual([span(21)]); + + // Test that it works on update, too. This time the log is a bit different + // because we started with reducerB instead of reducerA. + counter.current.dispatch('reset'); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Render: 0', + 'Render: 1', + 'Render: 11', + 'Render: 12', + 'Render: 22', + 22, + ]); + expect(ReactNoop.getChildren()).toEqual([span(22)]); + }); + }); + + describe('useReducer', () => { + it('simple mount and update', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useAPI(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + counter.current.dispatch(INCREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); + }); + + it('accepts an initial action', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INITIALIZE': + return 10; + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } + + const initialAction = 'INITIALIZE'; + + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0, initialAction); + useAPI(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]); + + counter.current.dispatch(INCREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + ReactNoop.flush(); + expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]); + }); + }); + + describe('useEffect', () => { + it('simple mount and update', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did commit [${props.count}]`); + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did commit [0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 1', 'Did commit [1]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + it('unmounts previous effect', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did create [${props.count}]`); + return () => { + ReactNoop.yield(`Did destroy [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Count: 1', + 'Did destroy [0]', + 'Did create [1]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + it('unmounts on deletion', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did create [${props.count}]`); + return () => { + ReactNoop.yield(`Did destroy [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('skips effect if constructor has not changed', () => { + function effect() { + ReactNoop.yield(`Did mount`); + return () => { + ReactNoop.yield(`Did unmount`); + }; + } + function Counter(props) { + useEffect(effect); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did mount']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + // No effect, because constructor was hoisted outside render + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Did unmount']); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + it('skips effect if inputs have not changed', () => { + function Counter(props) { + const text = `${props.label}: ${props.count}`; + useEffect( + () => { + ReactNoop.yield(`Did create [${text}]`); + return () => { + ReactNoop.yield(`Did destroy [${text}]`); + }; + }, + [props.label, props.count], + ); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Count: 0', 'Did create [Count: 0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + // Count changed + expect(ReactNoop.flush()).toEqual([ + 'Count: 1', + 'Did destroy [Count: 0]', + 'Did create [Count: 1]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(); + // Nothing changed, so no effect should have fired + expect(ReactNoop.flush()).toEqual(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(); + // Label changed + expect(ReactNoop.flush()).toEqual([ + 'Total: 1', + 'Did destroy [Count: 1]', + 'Did create [Total: 1]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]); + }); + + it('multiple effects', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Did commit 1 [${props.count}]`); + }); + useEffect(() => { + ReactNoop.yield(`Did commit 2 [${props.count}]`); + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Count: 0', + 'Did commit 1 [0]', + 'Did commit 2 [0]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Count: 1', + 'Did commit 1 [1]', + 'Did commit 2 [1]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + + it('unmounts all previous effects before creating any new ones', () => { + function Counter(props) { + useEffect(() => { + ReactNoop.yield(`Mount A [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + ReactNoop.yield(`Mount B [${props.count}]`); + return () => { + ReactNoop.yield(`Unmount B [${props.count}]`); + }; + }); + return ; + } + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Count: 0', + 'Mount A [0]', + 'Mount B [0]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + 'Count: 1', + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + }); + + describe('useCallback', () => { + it('memoizes callback by comparing inputs', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.increment(); + }; + render() { + return ; + } + } + + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useCallback(() => updateCount(c => c + incrementBy), [ + incrementBy, + ]); + return ( + + + + + ); + } + + const button = React.createRef(null); + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Increment', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 0'), + ]); + + button.current.increment(); + expect(ReactNoop.flush()).toEqual([ + // Button should not re-render, because its props haven't changed + // 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + // Increase the increment amount + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([ + // Inputs did change this time + 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); + + // Callback should have updated + button.current.increment(); + expect(ReactNoop.flush()).toEqual(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 11'), + ]); + }); + }); + + describe('useMemo', () => { + it('memoizes value by comparing to previous inputs', () => { + function CapitalizedText(props) { + const text = props.text; + const capitalizedText = useMemo( + () => { + ReactNoop.yield(`Capitalize '${text}'`); + return text.toUpperCase(); + }, + [text], + ); + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(["Capitalize 'hello'", 'HELLO']); + expect(ReactNoop.getChildren()).toEqual([span('HELLO')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(["Capitalize 'hi'", 'HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(["Capitalize 'goodbye'", 'GOODBYE']); + expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); + }); + + it('compares function if no inputs are provided', () => { + function LazyCompute(props) { + const computed = useMemo(props.compute); + return ; + } + + function computeA() { + ReactNoop.yield('compute A'); + return 'A'; + } + + function computeB() { + ReactNoop.yield('compute B'); + return 'B'; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['compute A', 'A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['compute B', 'B']); + }); + }); + + describe('useRef', () => { + it('creates a ref object initialized with the provided value', () => { + jest.useFakeTimers(); + + function useDebouncedCallback(callback, ms, inputs) { + const timeoutID = useRef(-1); + useEffect(() => { + return function unmount() { + clearTimeout(timeoutID.current); + }; + }, []); + const debouncedCallback = useCallback( + (...args) => { + clearTimeout(timeoutID.current); + timeoutID.current = setTimeout(callback, ms, ...args); + }, + [callback, ms], + ); + return useCallback(debouncedCallback, inputs); + } + + let ping; + function App() { + ping = useDebouncedCallback( + value => { + ReactNoop.yield('ping: ' + value); + }, + 100, + [], + ); + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual([]); + + ping(1); + ping(2); + ping(3); + + expect(ReactNoop.flush()).toEqual([]); + + jest.advanceTimersByTime(100); + + expect(ReactNoop.flush()).toEqual(['ping: 3']); + + ping(4); + jest.advanceTimersByTime(20); + ping(5); + ping(6); + jest.advanceTimersByTime(80); + + expect(ReactNoop.flush()).toEqual([]); + + jest.advanceTimersByTime(20); + expect(ReactNoop.flush()).toEqual(['ping: 6']); + }); + }); + + describe('progressive enhancement', () => { + it('mount additional state', () => { + let updateA; + let updateB; + let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + const [_C, _updateC] = useState(0); + C = _C; + updateC = _updateC; + } else { + C = '[not loaded]'; + } + + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 0, B: 0, C: [not loaded]'), + ]); + + updateA(2); + updateB(3); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 0']); + 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', () => { + let updateA; + let updateB; + let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + const [_C, _updateC] = useState(0); + C = _C; + updateC = _updateC; + } else { + C = '[not loaded]'; + } + + return ; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: 0']); + expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]); + + updateA(2); + updateB(3); + updateC(4); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']); + expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); + + updateC(4); + // TODO: This hook triggered a re-render even though it's unmounted. + // Should we warn? + expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); + + updateB(4); + expect(ReactNoop.flush()).toEqual(['A: 2, B: 4, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 4, C: [not loaded]'), + ]); + }); + + it('unmount effects', () => { + function App(props) { + useEffect(() => { + ReactNoop.yield('Mount A'); + return () => { + ReactNoop.yield('Unmount A'); + }; + }, []); + + if (props.showMore) { + useEffect(() => { + ReactNoop.yield('Mount B'); + return () => { + ReactNoop.yield('Unmount B'); + }; + }, []); + } + + return null; + } + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Mount A']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Mount B']); + + ReactNoop.render(); + expect(ReactNoop.flush()).toEqual(['Unmount B']); + + ReactNoop.render(null); + expect(ReactNoop.flush()).toEqual(['Unmount A']); + }); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index 312affa1e818..4197b821b302 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -12,6 +12,7 @@ let ReactFeatureFlags = require('shared/ReactFeatureFlags'); let React = require('react'); +let useContext; let ReactNoop; let gen; @@ -21,6 +22,7 @@ describe('ReactNewContext', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; React = require('react'); + useContext = React.useContext; ReactNoop = require('react-noop-renderer'); gen = require('random-seed'); }); @@ -34,33 +36,26 @@ describe('ReactNewContext', () => { return {type: 'span', children: [], prop, hidden: false}; } - function readContext(Context, observedBits) { - const dispatcher = - React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner - .currentDispatcher; - return dispatcher.readContext(Context, observedBits); - } - // We have several ways of reading from context. sharedContextTests runs // a suite of tests for a given context consumer implementation. sharedContextTests('Context.Consumer', Context => Context.Consumer); sharedContextTests( - 'readContext(Context) inside function component', + 'useContext inside functional component', Context => function Consumer(props) { const observedBits = props.unstable_observedBits; - const contextValue = readContext(Context, observedBits); + const contextValue = useContext(Context, observedBits); const render = props.children; return render(contextValue); }, ); sharedContextTests( - 'readContext(Context) inside class component', + 'useContext inside class component', Context => class Consumer extends React.Component { render() { const observedBits = this.props.unstable_observedBits; - const contextValue = readContext(Context, observedBits); + const contextValue = useContext(Context, observedBits); const render = this.props.children; return render(contextValue); } @@ -1194,7 +1189,7 @@ describe('ReactNewContext', () => { return ( {foo => { - const bar = readContext(BarContext); + const bar = useContext(BarContext); return ; }} @@ -1238,7 +1233,7 @@ describe('ReactNewContext', () => { }); }); - describe('readContext', () => { + describe('useContext', () => { it('can use the same context multiple times in the same function', () => { const Context = React.createContext({foo: 0, bar: 0, baz: 0}, (a, b) => { let result = 0; @@ -1264,13 +1259,13 @@ describe('ReactNewContext', () => { } function FooAndBar() { - const {foo} = readContext(Context, 0b001); - const {bar} = readContext(Context, 0b010); + const {foo} = useContext(Context, 0b001); + const {bar} = useContext(Context, 0b010); return ; } function Baz() { - const {baz} = readContext(Context, 0b100); + const {baz} = useContext(Context, 0b100); return ; } diff --git a/packages/react/src/React.js b/packages/react/src/React.js index b04aef44cc69..f843ad7c7271 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -27,6 +27,16 @@ import {createContext} from './ReactContext'; import {lazy} from './ReactLazy'; import forwardRef from './forwardRef'; import memo from './memo'; +import { + useContext, + useState, + useReducer, + useRef, + useEffect, + useCallback, + useMemo, + useAPI, +} from './ReactHooks'; import { createElementWithValidation, createFactoryWithValidation, @@ -53,6 +63,15 @@ const React = { lazy, memo, + useContext, + useState, + useReducer, + useRef, + useEffect, + useCallback, + useMemo, + useAPI, + Fragment: REACT_FRAGMENT_TYPE, StrictMode: REACT_STRICT_MODE_TYPE, Suspense: REACT_SUSPENSE_TYPE, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js new file mode 100644 index 000000000000..89b5b2977c7b --- /dev/null +++ b/packages/react/src/ReactHooks.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactContext} from 'shared/ReactTypes'; + +import invariant from 'shared/invariant'; + +import ReactCurrentOwner from './ReactCurrentOwner'; + +function resolveDispatcher() { + const dispatcher = ReactCurrentOwner.currentDispatcher; + invariant( + dispatcher !== null, + 'Hooks can only be called inside the body of a functional component.', + ); + return dispatcher; +} + +export function useContext( + Context: ReactContext, + observedBits: number | boolean | void, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.readContext(Context, observedBits); +} + +export function useState(initialState: S | (() => S)) { + const dispatcher = resolveDispatcher(); + return dispatcher.useState(initialState); +} + +export function useReducer( + reducer: (S, A) => S, + initialState: S, + initialAction: A | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useReducer(reducer, initialState, initialAction); +} + +export function useRef(initialValue: T): {current: T} { + const dispatcher = resolveDispatcher(); + return dispatcher.useRef(initialValue); +} + +export function useEffect( + create: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useEffect(create, inputs); +} + +export function useCallback( + callback: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useCallback(callback, inputs); +} + +export function useMemo( + create: () => mixed, + inputs: Array | void | null, +) { + const dispatcher = resolveDispatcher(); + return dispatcher.useMemo(create, inputs); +} + +export function useAPI( + ref: {current: T | null} | ((inst: T | null) => mixed) | null | void, + create: () => T, + inputs: Array | void | null, +): void { + const dispatcher = resolveDispatcher(); + return dispatcher.useAPI(ref, create, inputs); +}