diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index a51116219927..66e391dfb06b 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -34,6 +34,7 @@ import { enableUserTimingAPI, enableScopeAPI, enableChunksAPI, + enableContextReaderPropagation, } from 'shared/ReactFeatureFlags'; import {NoEffect, Placement} from 'shared/ReactSideEffectTags'; import {ConcurrentRoot, BlockingRoot} from 'shared/ReactRootTags'; @@ -116,6 +117,9 @@ if (__DEV__) { export type Dependencies = { expirationTime: ExpirationTime, firstContext: ContextDependency | null, + previousFirstContext?: ContextDependency | null, + contextSet?: Set> | null, + cleanupSet?: Set<() => mixed> | null, responders: Map< ReactEventResponder, ReactEventResponderInstance, @@ -468,14 +472,28 @@ export function createWorkInProgress( // Clone the dependencies object. This is mutated during the render phase, so // it cannot be shared with the current fiber. const currentDependencies = current.dependencies; - workInProgress.dependencies = - currentDependencies === null - ? null - : { - expirationTime: currentDependencies.expirationTime, - firstContext: currentDependencies.firstContext, - responders: currentDependencies.responders, - }; + if (enableContextReaderPropagation) { + workInProgress.dependencies = + currentDependencies === null + ? null + : { + expirationTime: currentDependencies.expirationTime, + firstContext: currentDependencies.firstContext, + previousFirstContext: currentDependencies.firstContext, + contextSet: currentDependencies.contextSet, + cleanupSet: currentDependencies.cleanupSet, + responders: currentDependencies.responders, + }; + } else { + workInProgress.dependencies = + currentDependencies === null + ? null + : { + expirationTime: currentDependencies.expirationTime, + firstContext: currentDependencies.firstContext, + responders: currentDependencies.responders, + }; + } // These will be overridden during the parent's reconciliation workInProgress.sibling = current.sibling; @@ -563,14 +581,28 @@ export function resetWorkInProgress( // Clone the dependencies object. This is mutated during the render phase, so // it cannot be shared with the current fiber. const currentDependencies = current.dependencies; - workInProgress.dependencies = - currentDependencies === null - ? null - : { - expirationTime: currentDependencies.expirationTime, - firstContext: currentDependencies.firstContext, - responders: currentDependencies.responders, - }; + if (enableContextReaderPropagation) { + workInProgress.dependencies = + currentDependencies === null + ? null + : { + expirationTime: currentDependencies.expirationTime, + firstContext: currentDependencies.firstContext, + previousFirstContext: currentDependencies.firstContext, + contextSet: currentDependencies.contextSet, + cleanupSet: currentDependencies.cleanupSet, + responders: currentDependencies.responders, + }; + } else { + workInProgress.dependencies = + currentDependencies === null + ? null + : { + expirationTime: currentDependencies.expirationTime, + firstContext: currentDependencies.firstContext, + responders: currentDependencies.responders, + }; + } if (enableProfilerTimer) { // Note: We don't reset the actualTime counts. It's useful to accumulate diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 9db5886a6c2b..c4c91166f0dd 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -66,6 +66,7 @@ import { warnAboutDefaultPropsOnFunctionComponents, enableScopeAPI, enableChunksAPI, + enableReifyNextWork, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -107,6 +108,7 @@ import { ProfileMode, StrictMode, BlockingMode, + ReifiedWorkMode, } from './ReactTypeOfMode'; import { shouldSetTextContent, @@ -131,13 +133,18 @@ import { import {findFirstSuspended} from './ReactFiberSuspenseComponent'; import { pushProvider, + popProvider, propagateContextChange, readContext, prepareToReadContext, calculateChangedBits, scheduleWorkOnParentPath, } from './ReactFiberNewContext'; -import {renderWithHooks, bailoutHooks} from './ReactFiberHooks'; +import { + renderWithHooks, + bailoutHooks, + canBailoutSpeculativeWorkWithHooks, +} from './ReactFiberHooks'; import {stopProfilerTimerIfRunning} from './ReactProfilerTimer'; import { getMaskedContext, @@ -671,7 +678,7 @@ function updateFunctionComponent( ); } - if (current !== null && !didReceiveUpdate) { + if (!enableReifyNextWork && current !== null && !didReceiveUpdate) { bailoutHooks(current, workInProgress, renderExpirationTime); return bailoutOnAlreadyFinishedWork( current, @@ -1457,7 +1464,6 @@ function mountIndeterminateComponent( getComponentName(Component) || 'Unknown', ); } - if ( debugRenderPhaseSideEffectsForStrictMode && workInProgress.mode & StrictMode @@ -2785,6 +2791,143 @@ export function markWorkInProgressReceivedUpdate() { didReceiveUpdate = true; } +function reifyNextWork(workInProgress: Fiber, renderExpirationTime) { + // console.log('reifying next work from ', workInProgress.tag); + let fiber = workInProgress.child; + if (fiber !== null) { + // Set the return pointer of the child to the work-in-progress fiber. + fiber.return = workInProgress; + } + + while (fiber !== null) { + let nextFiber; + + if (fiber.mode & ReifiedWorkMode) { + // this fiber and it's sub tree have already been reified. whatever work exists + // there we need to progress forward with it. no need to delve deeper + nextFiber = null; + } else { + fiber.mode |= ReifiedWorkMode; + + // console.log( + // '-- checking', + // fiber.tag, + // fiber._debugNeedsRemount, + // fiber.type && fiber.type.name, + // ); + // fiber.alternate && + // console.log( + // '-- checking alternate', + // fiber.alternate.tag, + // fiber.alternate._debugNeedsRemount, + // fiber.type === fiber.alternate.type, + // ); + + if (__DEV__ && fiber._debugNeedsRemount) { + // this fiber needs to be remounted. we can bail out of the reify algo for this + // subtree and let normal work takes it's course + console.log('fiber', fiber.tag, 'has debug marker'); + nextFiber = null; + } else if (fiber.expirationTime >= renderExpirationTime) { + let didBailout; + switch (fiber.tag) { + case ForwardRef: + case SimpleMemoComponent: + case FunctionComponent: { + try { + didBailout = canBailoutSpeculativeWorkWithHooks( + fiber, + renderExpirationTime, + ); + } catch (e) { + // suppress error and do not bailout. it should error again + // when the component renders when the context selector is run + // or when the reducer state is updated + didBailout = false; + } + + break; + } + case ClassComponent: { + // class component is not yet supported for bailing out of context and state updates + // but it support should be possible + // @TODO implement a ClassComponent bailout here + didBailout = false; + break; + } + default: { + // in unsupported cases we cannot bail out of work and we + // consider the speculative work reified + didBailout = false; + } + } + if (didBailout) { + fiber.expirationTime = NoWork; + nextFiber = fiber.child; + } else { + nextFiber = null; + } + } else if (fiber.childExpirationTime >= renderExpirationTime) { + nextFiber = fiber.child; + } else { + nextFiber = null; + } + } + + if (fiber.tag === ContextProvider) { + const newValue = fiber.memoizedProps.value; + pushProvider(fiber, newValue); + } + + if (nextFiber !== null) { + nextFiber.return = fiber; + } else { + // No child. Traverse to next sibling. + nextFiber = fiber; + while (nextFiber !== null) { + if (nextFiber === workInProgress) { + // We're back to the root of this subtree. Exit. + nextFiber = null; + break; + } + if (nextFiber.tag === ContextProvider) { + popProvider(nextFiber); + } + let sibling = nextFiber.sibling; + if (sibling !== null) { + // Set the return pointer of the sibling to the work-in-progress fiber. + sibling.return = nextFiber.return; + nextFiber = sibling; + break; + } + // No more siblings. Traverse up. + nextFiber = nextFiber.return; + resetChildExpirationTime(nextFiber); + } + } + fiber = nextFiber; + } +} + +function resetChildExpirationTime(fiber: Fiber) { + let newChildExpirationTime = NoWork; + + let child = fiber.child; + while (child !== null) { + const childUpdateExpirationTime = child.expirationTime; + const childChildExpirationTime = child.childExpirationTime; + if (childUpdateExpirationTime > newChildExpirationTime) { + newChildExpirationTime = childUpdateExpirationTime; + } + if (childChildExpirationTime > newChildExpirationTime) { + newChildExpirationTime = childChildExpirationTime; + } + child = child.sibling; + } + + fiber.childExpirationTime = newChildExpirationTime; +} + function bailoutOnAlreadyFinishedWork( current: Fiber | null, workInProgress: Fiber, @@ -2807,6 +2950,16 @@ function bailoutOnAlreadyFinishedWork( markUnprocessedUpdateTime(updateExpirationTime); } + if (enableReifyNextWork) { + if (workInProgress.childExpirationTime >= renderExpirationTime) { + if (workInProgress.mode & ReifiedWorkMode) { + // noop, we don't need to do any checking if we've already done it + } else { + reifyNextWork(workInProgress, renderExpirationTime); + } + } + } + // Check if the children have any pending work. const childExpirationTime = workInProgress.childExpirationTime; if (childExpirationTime < renderExpirationTime) { @@ -2892,6 +3045,8 @@ function beginWork( ): Fiber | null { const updateExpirationTime = workInProgress.expirationTime; + // console.log('beginWork', workInProgress.tag); + if (__DEV__) { if (workInProgress._debugNeedsRemount && current !== null) { // This will restart the begin phase with a new fiber. diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 9d9d220f33d5..aeccc6c52652 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -34,6 +34,7 @@ import { enableFundamentalAPI, enableSuspenseCallback, enableScopeAPI, + enableContextReaderPropagation, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -77,6 +78,7 @@ import {logCapturedError} from './ReactFiberErrorLogger'; import {resolveDefaultProps} from './ReactFiberLazyComponent'; import {getCommitTime} from './ReactProfilerTimer'; import {commitUpdateQueue} from './ReactUpdateQueue'; +import {cleanupReadersOnUnmount} from './ReactFiberNewContext'; import { getPublicInstance, supportsMutation, @@ -789,6 +791,9 @@ function commitUnmount( case MemoComponent: case SimpleMemoComponent: case Chunk: { + if (enableContextReaderPropagation) { + cleanupReadersOnUnmount(current); + } const updateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any); if (updateQueue !== null) { const lastEffect = updateQueue.lastEffect; @@ -841,6 +846,9 @@ function commitUnmount( return; } case ClassComponent: { + if (enableContextReaderPropagation) { + cleanupReadersOnUnmount(current); + } safelyDetachRef(current); const instance = current.stateNode; if (typeof instance.componentWillUnmount === 'function') { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 1138dc57116b..94e7a2ea4fb2 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -120,6 +120,7 @@ import { enableFundamentalAPI, enableScopeAPI, enableChunksAPI, + enableContextReaderPropagation, } from 'shared/ReactFeatureFlags'; import { markSpawnedWork, @@ -973,6 +974,11 @@ function completeWork( updateHostContainer(workInProgress); return null; case ContextProvider: + if (enableContextReaderPropagation) { + // capture readers and store on memoizedState + workInProgress.memoizedState = + workInProgress.type._context._currentReaders; + } // Pop provider fiber popProvider(workInProgress); return null; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 851e22f8fe83..f9cdbf7d80ed 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -21,7 +21,11 @@ import type {ReactPriorityLevel} from './SchedulerWithReactIntegration'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import {NoWork, Sync} from './ReactFiberExpirationTime'; -import {readContext} from './ReactFiberNewContext'; +import { + readContext as originalReadContext, + peekContext, +} from './ReactFiberNewContext'; + import {createDeprecatedResponderListener} from './ReactFiberDeprecatedEvents'; import { Update as UpdateEffect, @@ -42,6 +46,10 @@ import { markRenderEventTimeAndConfig, markUnprocessedUpdateTime, } from './ReactFiberWorkLoop'; +import { + enableContextSelectors, + enableReifyNextWork, +} from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import getComponentName from 'shared/getComponentName'; @@ -57,6 +65,16 @@ import { const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals; +const readContext = originalReadContext; +const mountContext = enableContextSelectors + ? mountContextImpl + : originalReadContext; +const updateContext = enableContextSelectors + ? updateContextImpl + : originalReadContext; + +type ObservedBits = void | number | boolean; + export type Dispatcher = {| readContext( context: ReactContext, @@ -343,6 +361,24 @@ function areHookInputsEqual( return true; } +export function canBailoutSpeculativeWorkWithHooks( + current: Fiber, + nextRenderExpirationTime: renderExpirationTime, +): boolean { + let hook = current.memoizedState; + while (hook !== null) { + // hooks without a bailout are assumed to permit bailing out + // any hook which can instigate work needs to implement the bailout API + if (typeof hook.bailout === 'function') { + if (!hook.bailout(hook, current, nextRenderExpirationTime)) { + return false; + } + } + hook = hook.next; + } + return true; +} + export function renderWithHooks( current: Fiber | null, workInProgress: Fiber, @@ -480,6 +516,7 @@ export function renderWithHooks( return children; } +// @TODO may need to reset context hooks now that they have memoizedState export function bailoutHooks( current: Fiber, workInProgress: Fiber, @@ -492,6 +529,7 @@ export function bailoutHooks( } } +// @TODO may need to reset context hooks now that they have memoizedState export function resetHooksAfterThrow(): void { // 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. @@ -540,6 +578,8 @@ function mountWorkInProgressHook(): Hook { baseQueue: null, queue: null, + bailout: null, + next: null, }; @@ -600,6 +640,8 @@ function updateWorkInProgressHook(): Hook { baseQueue: currentHook.baseQueue, queue: currentHook.queue, + bailout: currentHook.bailout, + next: null, }; @@ -614,6 +656,114 @@ function updateWorkInProgressHook(): Hook { return workInProgressHook; } +// wanted to use a symbol here to represent no value given undefiend and null should be valid +// selections. However Symbol was causing errors in tests so using an empty object to get the +// same effect +// const EMPTY = Symbol('empty'); +const EMPTY = {}; + +function mountContextImpl( + context: ReactContext, + selector?: ObservedBits | (C => any), +): any { + const hook = mountWorkInProgressHook(); + if (typeof selector === 'function') { + let contextValue = readContext(context); + let selection = selector(contextValue); + hook.memoizedState = { + context, + contextValue, + selector, + selection, + stashedContextValue: EMPTY, + stashedSelection: EMPTY, + }; + hook.bailout = bailoutContext; + return selection; + } else { + // selector is the legacy observedBits api + let observedBits = selector; + let contextValue = readContext(context, observedBits); + hook.memoizedState = { + context, + contextValue, + selector: null, + selection: EMPTY, + stashedContextValue: EMPTY, + stashedSelection: EMPTY, + }; + hook.bailout = bailoutContext; + return contextValue; + } +} + +function updateContextImpl( + context: ReactContext, + selector?: ObservedBits | (C => any), +): any { + const hook = updateWorkInProgressHook(); + const memoizedState = hook.memoizedState; + if (memoizedState.stashedSelection !== EMPTY) { + // context and selector could not have changed since we attempted a bailout + memoizedState.contextValue = memoizedState.stashedContextValue; + memoizedState.selection = memoizedState.stashedSelection; + memoizedState.stashedContextValue = EMPTY; + memoizedState.stashedSelection = EMPTY; + // need to readContext to reset dependency + readContext(context); + return memoizedState.selection; + } + memoizedState.context = context; + if (typeof selector === 'function') { + let selection = memoizedState.selection; + let contextValue = readContext(context); + + // when using a selector only recomput if the context value is different or + // the selector is different than the memoized state + if ( + contextValue !== memoizedState.contextValue || + selector !== memoizedState.selector + ) { + selection = selector(contextValue); + memoizedState.contextValue = contextValue; + memoizedState.selector = selector; + memoizedState.selection = selection; + } + return selection; + } else { + // selector is actually observedBits + let observedBits = selector; + let contextValue = readContext(context, observedBits); + memoizedState.contextValue = contextValue; + return contextValue; + } +} + +function bailoutContext(hook: Hook): boolean { + const memoizedState = hook.memoizedState; + const selector = memoizedState.selector; + const peekedContextValue = peekContext(memoizedState.context); + const previousContextValue = memoizedState.contextValue; + + // if this context hook uses a selector we need to check if the selection + // has changed with a new context value + if (selector !== null) { + if (previousContextValue !== peekedContextValue) { + const stashedSelection = selector(peekedContextValue); + // stashed selections will get applied to the hook's memoized state as + // part of the render phase of the workInProgress fiber + memoizedState.stashedSelection = stashedSelection; + memoizedState.stashedContextValue = peekedContextValue; + if (stashedSelection !== memoizedState.selection) { + return false; + } + } + } else if (previousContextValue !== peekedContextValue) { + return false; + } + return true; +} + function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { return { lastEffect: null, @@ -644,6 +794,7 @@ function mountReducer( lastRenderedReducer: reducer, lastRenderedState: (initialState: any), }); + hook.bailout = bailoutReducer; const dispatch: Dispatch = (queue.dispatch = (dispatchAction.bind( null, currentlyRenderingFiber, @@ -837,6 +988,120 @@ function rerenderReducer( return [newState, dispatch]; } +function bailoutReducer( + hook: Hook, + current: Fiber, + renderExpirationTime: ExpirationTime, +): boolean { + // this is mostly a clone of updateReducer + // it has been modified to work without a workInProgress hook + // and to prepare a toBeApplied state on the next render + // @TODO consider what happens if work is yielded between the bailout attempt + // and the render attempt + const queue = hook.queue; + const reducer = queue.lastRenderedReducer; + + // The last rebase update that is NOT part of the base state. + let baseQueue = hook.baseQueue; + + // The last pending update that hasn't been processed yet. + let pendingQueue = queue.pending; + if (pendingQueue !== null) { + // We have new updates that haven't been processed yet. + // We'll add them to the base queue. + if (baseQueue !== null) { + // Merge the pending queue and the base queue. + let baseFirst = baseQueue.next; + let pendingFirst = pendingQueue.next; + baseQueue.next = pendingFirst; + pendingQueue.next = baseFirst; + } + hook.baseQueue = baseQueue = pendingQueue; + queue.pending = null; + } + + // will be set to false if there are updates to process + let canBailout = true; + + if (baseQueue !== null) { + // We have a queue to process. + let first = baseQueue.next; + let newState = hook.baseState; + + let newBaseState = null; + let newBaseQueueFirst = null; + let newBaseQueueLast = null; + let update = first; + 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. + const clone: Update = { + expirationTime: update.expirationTime, + suspenseConfig: update.suspenseConfig, + action: update.action, + eagerReducer: update.eagerReducer, + eagerState: update.eagerState, + next: (null: any), + }; + if (newBaseQueueLast === null) { + newBaseQueueFirst = newBaseQueueLast = clone; + newBaseState = newState; + } else { + newBaseQueueLast = newBaseQueueLast.next = clone; + } + // Update the remaining priority in the queue. + if (updateExpirationTime > current.expirationTime) { + current.expirationTime = updateExpirationTime; + markUnprocessedUpdateTime(updateExpirationTime); + } + } else { + // This update does have sufficient priority. + + if (newBaseQueueLast !== null) { + const clone: Update = { + expirationTime: Sync, // This update is going to be committed so we never want uncommit it. + suspenseConfig: update.suspenseConfig, + action: update.action, + eagerReducer: update.eagerReducer, + eagerState: update.eagerState, + next: (null: any), + }; + newBaseQueueLast = newBaseQueueLast.next = clone; + } + + // Process this update. + const action = update.action; + newState = reducer(newState, action); + } + update = update.next; + } while (update !== null && update !== first); + + if (is(newState, hook.memoizedState)) { + // the new state is the same as the memoizedState, + // the updates in this queue do not require work + canBailout = true; + } else { + // the new state is different. we cannot bail out of work + canBailout = false; + } + + if (newBaseQueueLast === null) { + newBaseState = newState; + } else { + newBaseQueueLast.next = (newBaseQueueFirst: any); + } + + hook.memoizedState = newState; + hook.baseState = newBaseState; + hook.baseQueue = newBaseQueueLast; + } + + return canBailout; +} + function mountState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -852,6 +1117,7 @@ function mountState( lastRenderedReducer: basicStateReducer, lastRenderedState: (initialState: any), }); + hook.bailout = bailoutReducer; const dispatch: Dispatch< BasicStateAction, > = (queue.dispatch = (dispatchAction.bind( @@ -1320,6 +1586,7 @@ function dispatchAction( currentlyRenderingFiber.expirationTime = renderExpirationTime; } else { if ( + !enableReifyNextWork && fiber.expirationTime === NoWork && (alternate === null || alternate.expirationTime === NoWork) ) { @@ -1391,7 +1658,7 @@ const HooksDispatcherOnMount: Dispatcher = { readContext, useCallback: mountCallback, - useContext: readContext, + useContext: mountContext, useEffect: mountEffect, useImperativeHandle: mountImperativeHandle, useLayoutEffect: mountLayoutEffect, @@ -1409,7 +1676,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { readContext, useCallback: updateCallback, - useContext: readContext, + useContext: updateContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -1427,7 +1694,7 @@ const HooksDispatcherOnRerender: Dispatcher = { readContext, useCallback: updateCallback, - useContext: readContext, + useContext: updateContext, useEffect: updateEffect, useImperativeHandle: updateImperativeHandle, useLayoutEffect: updateLayoutEffect, @@ -1484,11 +1751,11 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; mountHookTypesDev(); - return readContext(context, observedBits); + return mountContext(context, selector); }, useEffect( create: () => (() => void) | void, @@ -1605,11 +1872,11 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; updateHookTypesDev(); - return readContext(context, observedBits); + return mountContext(context, selector); }, useEffect( create: () => (() => void) | void, @@ -1722,11 +1989,11 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; updateHookTypesDev(); - return readContext(context, observedBits); + return updateContext(context, selector); }, useEffect( create: () => (() => void) | void, @@ -1839,11 +2106,11 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; updateHookTypesDev(); - return readContext(context, observedBits); + return updateContext(context, selector); }, useEffect( create: () => (() => void) | void, @@ -1958,12 +2225,12 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; warnInvalidHookAccess(); mountHookTypesDev(); - return readContext(context, observedBits); + return mountContext(context, selector); }, useEffect( create: () => (() => void) | void, @@ -2089,12 +2356,12 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; warnInvalidHookAccess(); updateHookTypesDev(); - return readContext(context, observedBits); + return updateContext(context, selector); }, useEffect( create: () => (() => void) | void, @@ -2220,12 +2487,12 @@ if (__DEV__) { }, useContext( context: ReactContext, - observedBits: void | number | boolean, + selector: ObservedBits | (T => any), ): T { currentHookNameInDev = 'useContext'; warnInvalidHookAccess(); updateHookTypesDev(); - return readContext(context, observedBits); + return updateContext(context, selector); }, useEffect( create: () => (() => void) | void, diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.js b/packages/react-reconciler/src/ReactFiberHotReloading.js index ec280e96567c..94f973523128 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.js @@ -319,7 +319,7 @@ function scheduleFibersWithFamiliesRecursively( fiber._debugNeedsRemount = true; } if (needsRemount || needsRender) { - scheduleWork(fiber, Sync); + scheduleWork(fiber, Sync, true); } if (child !== null && !needsRemount) { scheduleFibersWithFamiliesRecursively( diff --git a/packages/react-reconciler/src/ReactFiberNewContext.js b/packages/react-reconciler/src/ReactFiberNewContext.js index 510c1eb674ad..3ea3d3d8cce1 100644 --- a/packages/react-reconciler/src/ReactFiberNewContext.js +++ b/packages/react-reconciler/src/ReactFiberNewContext.js @@ -11,6 +11,7 @@ import type {ReactContext} from 'shared/ReactTypes'; import type {Fiber} from './ReactFiber'; import type {StackCursor} from './ReactFiberStack'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import {ReifiedWorkMode} from './ReactTypeOfMode'; export type ContextDependency = { context: ReactContext, @@ -37,7 +38,11 @@ import { } from 'react-reconciler/src/ReactUpdateQueue'; import {NoWork} from './ReactFiberExpirationTime'; import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork'; -import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; +import { + enableSuspenseServerRenderer, + enableContextReaderPropagation, + enableReifyNextWork, +} from 'shared/ReactFeatureFlags'; const valueCursor: StackCursor = createCursor(null); @@ -50,6 +55,12 @@ if (__DEV__) { let currentlyRenderingFiber: Fiber | null = null; let lastContextDependency: ContextDependency | null = null; let lastContextWithAllBitsObserved: ReactContext | null = null; +let lastPreviousContextDependency: ContextDependency | null; +if (enableContextReaderPropagation) { + // this module global tracks the context dependency in the same slot as the + // lastContextDependency from the previously committed render + lastPreviousContextDependency = null; +} let isDisallowedContextReadInDEV: boolean = false; @@ -79,6 +90,12 @@ export function exitDisallowedContextReadInDEV(): void { export function pushProvider(providerFiber: Fiber, nextValue: T): void { const context: ReactContext = providerFiber.type._context; + if (enableContextReaderPropagation) { + // push the previousReaders onto the stack + push(valueCursor, context._currentReaders, providerFiber); + context._currentReaders = providerFiber.memoizedState; + } + if (isPrimaryRenderer) { push(valueCursor, context._currentValue, providerFiber); @@ -127,6 +144,12 @@ export function popProvider(providerFiber: Fiber): void { } else { context._currentValue2 = currentValue; } + + if (enableContextReaderPropagation) { + // pop the previousReaders off the stack and restore + let previousReaders = pop(valueCursor, providerFiber); + context._currentReaders = previousReaders; + } } export function calculateChangedBits( @@ -167,17 +190,26 @@ export function scheduleWorkOnParentPath( let alternate = node.alternate; if (node.childExpirationTime < renderExpirationTime) { node.childExpirationTime = renderExpirationTime; + if (enableReifyNextWork) { + node.mode &= ~ReifiedWorkMode; + } if ( alternate !== null && alternate.childExpirationTime < renderExpirationTime ) { alternate.childExpirationTime = renderExpirationTime; + if (enableReifyNextWork) { + alternate.mode &= ~ReifiedWorkMode; + } } } else if ( alternate !== null && alternate.childExpirationTime < renderExpirationTime ) { alternate.childExpirationTime = renderExpirationTime; + if (enableReifyNextWork) { + alternate.mode &= ~ReifiedWorkMode; + } } else { // Neither alternate was updated, which means the rest of the // ancestor path already has sufficient priority. @@ -193,6 +225,16 @@ export function propagateContextChange( changedBits: number, renderExpirationTime: ExpirationTime, ): void { + if (enableContextReaderPropagation) { + // instead of the traditional propagation we are going to use + // readers exclusively to fast path to dependent fibers + return propagateContextChangeToReaders( + workInProgress, + context, + changedBits, + renderExpirationTime, + ); + } let fiber = workInProgress.child; if (fiber !== null) { // Set the return pointer of the child to the work-in-progress fiber. @@ -228,6 +270,9 @@ export function propagateContextChange( if (fiber.expirationTime < renderExpirationTime) { fiber.expirationTime = renderExpirationTime; + if (enableReifyNextWork) { + fiber.mode &= ~ReifiedWorkMode; + } } let alternate = fiber.alternate; if ( @@ -235,6 +280,9 @@ export function propagateContextChange( alternate.expirationTime < renderExpirationTime ) { alternate.expirationTime = renderExpirationTime; + if (enableReifyNextWork) { + alternate.mode &= ~ReifiedWorkMode; + } } scheduleWorkOnParentPath(fiber.return, renderExpirationTime); @@ -320,10 +368,17 @@ export function prepareToReadContext( currentlyRenderingFiber = workInProgress; lastContextDependency = null; lastContextWithAllBitsObserved = null; + if (enableContextReaderPropagation) { + lastPreviousContextDependency = null; + } const dependencies = workInProgress.dependencies; if (dependencies !== null) { const firstContext = dependencies.firstContext; + if (enableContextReaderPropagation) { + dependencies.previousFirstContext = firstContext; + lastPreviousContextDependency = dependencies.previousFirstContext; + } if (firstContext !== null) { if (dependencies.expirationTime >= renderExpirationTime) { // Context list has a pending update. Mark that this fiber performed work. @@ -335,6 +390,186 @@ export function prepareToReadContext( } } +export function peekContext(context: ReactContext): T { + return isPrimaryRenderer ? context._currentValue : context._currentValue2; +} + +function propagateContextChangeToReaders( + workInProgress: Fiber, + context: ReactContext, + changedBits: number, + renderExpirationTime: ExpirationTime, +) { + let state = workInProgress.memoizedState; + if (state === null) { + // this Provider has no readers to propagate to + return; + } else { + let reader = state.firstReader; + while (reader !== null) { + // notify each read of the context change + reader.notify(context, changedBits, renderExpirationTime); + reader = reader.next; + } + } +} + +export function attachReader(contextItem) { + if (enableContextReaderPropagation) { + let context = contextItem.context; + // consider using bind on detachReader (the cleanup function) to avoid having to keep the closure alive + // for now we can just capture the currently rendering fiber for use in notify and cleanup + let readerFiber = currentlyRenderingFiber; + let currentReaders = context._currentReaders; + if (currentReaders == null) { + currentReaders = {firstReader: null}; + context._currentReaders = currentReaders; + } + let initialFirstReader = currentReaders.firstReader; + // readers are a doubly linked list of notify functions. the provide providers with direct access + // to fibers which currently do or have in the past read from this provider to allow avoiding the + // tree walk involved with propagation. This allows the time complexity of propagation to match the + // number of readers rather than the tree size + // it is doubly linked to allow for O(1) insert and removal. we could do singly + // and get O(1) insert and O(n) removal but for providers with many readers this felt more prudent + let reader = { + // @TODO switch to bind over using a closure + notify: (notifyingContext, changedBits, renderExpirationTime) => { + let list = readerFiber.dependencies; + let alternate = readerFiber.alternate; + let alternateList = alternate !== null ? alternate.dependencies : null; + // if the list already has the necessary expriation on it AND the alternate if it exists + // then we can bail out of notification + if ( + list.expirationTime >= renderExpirationTime && + readerFiber.expirationTime >= renderExpirationTime && + (alternate === null || + (alternateList !== null && + alternateList.expirationTime >= renderExpirationTime && + alternate.expriationTime >= renderExpirationTime)) + ) { + return; + } + let dependency = list.firstContext; + while (dependency !== null) { + // Check if the context matches. + if ( + dependency.context === notifyingContext && + (dependency.observedBits & changedBits) !== 0 + ) { + // Match! Schedule an update on this fiber. + + if (readerFiber.tag === ClassComponent) { + // Schedule a force update on the work-in-progress. + const update = createUpdate(renderExpirationTime, null); + update.tag = ForceUpdate; + // TODO: Because we don't have a work-in-progress, this will add the + // update to the current fiber, too, which means it will persist even if + // this render is thrown away. Since it's a race condition, not sure it's + // worth fixing. + enqueueUpdate(readerFiber, update); + } + + if (readerFiber.expirationTime < renderExpirationTime) { + readerFiber.expirationTime = renderExpirationTime; + if (enableReifyNextWork) { + readerFiber.mode &= ~ReifiedWorkMode; + } + } + if ( + alternate !== null && + alternate.expirationTime < renderExpirationTime + ) { + alternate.expirationTime = renderExpirationTime; + if (enableReifyNextWork) { + alternate.mode &= ~ReifiedWorkMode; + } + } + + scheduleWorkOnParentPath(readerFiber.return, renderExpirationTime); + + // Mark the expiration time on the list and if it exists the alternate's list + if (list.expirationTime < renderExpirationTime) { + list.expirationTime = renderExpirationTime; + } + if (alternateList !== null) { + if (alternateList.expirationTime < renderExpirationTime) { + alternateList.expirationTime = renderExpirationTime; + } + } + + // Since we already found a match, we can stop traversing the + // dependency list. + break; + } + dependency = dependency.next; + } + }, + next: null, + prev: null, + }; + if (__DEV__) { + // can be useful in distinguishing readers during debugging + // @TODO remove in the future + reader._tag = Math.random() + .toString(36) + .substring(2, 6); + reader._currentReaders = currentReaders; + } + currentReaders.firstReader = reader; + if (initialFirstReader !== null) { + reader.next = initialFirstReader; + initialFirstReader.prev = reader; + } + // return the cleanup function + // @TODO switch to bind instead of closure capture + return () => { + detachReader(currentReaders, reader); + }; + } +} + +function detachReader(currentReaders, reader) { + if (enableContextReaderPropagation) { + if (currentReaders.firstReader === reader) { + // if we are detaching the first item point our currentReaders + // to the next item first + currentReaders.firstReader = reader.next; + } + if (reader.next !== null) { + reader.next.prev = reader.prev; + } + if (reader.prev !== null) { + reader.prev.next = reader.next; + } + reader.prev = reader.next = null; + } +} + +export function cleanupReadersOnUnmount(fiber: Fiber) { + if (enableContextReaderPropagation) { + let dependencies = fiber.dependencies; + if (dependencies !== null) { + let {cleanupSet, firstContext} = dependencies; + if (cleanupSet !== null) { + // this fiber hosted a complex reader where cleanup functions were stored + // in a set + let iter = cleanupSet.values(); + for (let step = iter.next(); !step.done; step = iter.next()) { + step.value(); + } + } else if (firstContext !== null) { + // this fiber hosted a simple reader list + let contextItem = firstContext; + while (contextItem !== null) { + contextItem.cleanup(); + contextItem = contextItem.next; + } + } + } + } +} + export function readContext( context: ReactContext, observedBits: void | number | boolean, @@ -386,15 +621,134 @@ export function readContext( // This is the first dependency for this component. Create a new list. lastContextDependency = contextItem; - currentlyRenderingFiber.dependencies = { - expirationTime: NoWork, - firstContext: contextItem, - responders: null, - }; + + if (enableContextReaderPropagation) { + let existingContextSet = null; + let existingCleanupSet = null; + let previousFirstContext = null; + + let dependencies = currentlyRenderingFiber.dependencies; + if (dependencies !== null) { + existingContextSet = dependencies.contextSet; + existingCleanupSet = dependencies.cleanupSet; + previousFirstContext = dependencies.previousFirstContext; + } + + currentlyRenderingFiber.dependencies = { + expirationTime: NoWork, + firstContext: contextItem, + previousFirstContext: previousFirstContext, + contextSet: existingContextSet, + cleanupSet: existingCleanupSet, + responders: null, + }; + } else { + currentlyRenderingFiber.dependencies = { + expirationTime: NoWork, + firstContext: contextItem, + responders: null, + }; + } } else { // Append a new context item. lastContextDependency = lastContextDependency.next = contextItem; } + if (enableContextReaderPropagation) { + // there are two methods of tracking context reader fibers. For most fibers that + // read contexts the reads are stable over each render. ClassComponents, ContextConsumer, + // most uses of useContext, etc... However if you use readContext, or you change observedBits + // from render to render, or pass different contexts into useContext it is possible that the + // contexts you read from and their order in the dependency list won't be stable across renders + // + // the main goal is to attach readers during render so that we are included in the set of + // "fibers that need to know about new context values provided by the current provider" + // it is ok if we don't end up depending on that context in the future, it is safe to still + // be notified about future changes which is why this can be done during render phase + // + // cleaning up is trickier. we can only safely do that on umount because any given render could + // be yielded / thrown and not complete and we need to be able to restart without having + // a chance to restore the reader + // + // the algorithm here boils down to + // + // 1. if this is the first render we read from a context, attach readers for every context dependency. This may mean + // we have duplicates (especially in dev mode with useContext since the render function is called) + // twice + // + // 2. if this is not the first render on which we read from a context, check to see if each new + // dependency has the same context as the the dependency in this same slot of the list + // from the last committed render. if so we're in the stable case and can just copy the cleanup function + // over to the new contextItem; no need to call attach again. + // + // 3. instead if we find a conflicting context for this slot in the contextItem list + // we enter advanced tracking mode which creates a context Set and a cleanup Set + // these two sets will hold the maximal set of contexts attached to and cleanup + // functions related to said attachment gathered from the previous list, in addition + // to the current contextItem, attaching if necessary. + // + // 4. instead if we were already in advanced tracking mode we simply see if the current + // contextItem has a novel context and we attach it and store the cleanup function as necessary + // + // Note about memory leaks: + // this implementation does allow for some leakage. in particular if you read from fewer contexts + // on a second render we will 'lose' the initial cleanup functions since we do not activate advancedMode + // in that case. for the time being I'm not tackling that but there are a few ways I can imagine we do + // namely, move the list checking to the prepare step or calling a finalizer to complement the prepare + // step after the render invocation is finished + // additionally, it is technically not necessary to keep dead readers around (the reader for a context) + // no longer read from, but cleaning that up would add more code complexity, possibly lengthen the commit phase + // and leaving them is generally harmless since the nofication won't result in any work being scheduled + // + // @TODO the cleanupSet does not need to be a set, make it an array and simply push to it + if (lastPreviousContextDependency !== null) { + // previous list exists, we can see if we need to enter advanced + let dependencies = currentlyRenderingFiber.dependencies; + let {contextSet, cleanupSet} = dependencies; + if (contextSet !== null) { + // this fiber is already in complex tracking mode. let's attach if needed and add to sets + if (!contextSet.has(context)) { + cleanupSet.add(attachReader(contextItem)); + contextSet.add(context); + } + } else if ( + dependencies.contextSet === null && + lastContextDependency.context !== + lastPreviousContextDependency.context + ) { + // this fiber needs to switch to advanced tracking mode + contextSet = new Set(); + cleanupSet = new Set(); + + // fill the sets with everything from the previous commit + let currentDependency = dependencies.previousFirstContext; + while (currentDependency !== null) { + contextSet.add(currentDependency.context); + cleanupSet.add(currentDependency.cleanup); + currentDependency = currentDependency.next; + } + // attach and add this latest context if necessary + if (!contextSet.has(context)) { + cleanupSet.add(attachReader(contextItem)); + contextSet.add(context); + } + currentlyRenderingFiber.dependencies.contextSet = contextSet; + currentlyRenderingFiber.dependencies.cleanupSet = cleanupSet; + } else { + // in quick mode and context dependency has not changed, copy cleanup over + contextItem.cleanup = lastPreviousContextDependency.cleanup; + } + + // advance the lastPreviousContextDependency pointer in conjunction with each new contextItem. + // if it is null we don't advance it which means if another readContext happens in this pass + // for a different context we will end up entering advanced mode + if (lastPreviousContextDependency.next !== null) { + lastPreviousContextDependency = lastPreviousContextDependency.next; + } + } else { + // lastPreviousContextDependency does not exist so treating it like a mount and attaching readers + contextItem.cleanup = attachReader(contextItem); + } + } } return isPrimaryRenderer ? context._currentValue : context._currentValue2; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f647ccb88b2c..738412ae8131 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -28,6 +28,7 @@ import { flushSuspenseFallbacksInTests, disableSchedulerTimeoutBasedOnReactExpirationTime, enableTrainModelFix, + enableReifyNextWork, } from 'shared/ReactFeatureFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import invariant from 'shared/invariant'; @@ -78,6 +79,7 @@ import { ProfileMode, BlockingMode, ConcurrentMode, + ReifiedWorkMode, } from './ReactTypeOfMode'; import { HostRoot, @@ -378,11 +380,12 @@ export function computeExpirationForFiber( export function scheduleUpdateOnFiber( fiber: Fiber, expirationTime: ExpirationTime, + force?: boolean, ) { checkForNestedUpdates(); warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber); - const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime); + const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime, force); if (root === null) { warnAboutUpdateOnUnmountedFiberInDEV(fiber); return; @@ -451,14 +454,32 @@ export const scheduleWork = scheduleUpdateOnFiber; // work without treating it as a typical update that originates from an event; // e.g. retrying a Suspense boundary isn't an update, but it does schedule work // on a fiber. -function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { +function markUpdateTimeFromFiberToRoot(fiber, expirationTime, force) { // Update the source fiber's expiration time if (fiber.expirationTime < expirationTime) { fiber.expirationTime = expirationTime; + if (enableReifyNextWork) { + if (force === true) { + // console.log('marking path as reified'); + fiber.mode |= ReifiedWorkMode; + } else { + // console.log('marking path as NOT reified'); + fiber.mode &= ~ReifiedWorkMode; + } + } } let alternate = fiber.alternate; if (alternate !== null && alternate.expirationTime < expirationTime) { alternate.expirationTime = expirationTime; + if (enableReifyNextWork) { + if (force === true) { + // console.log('marking alternate path as reified'); + alternate.mode |= ReifiedWorkMode; + } else { + // console.log('marking alternate path as NOT reified'); + alternate.mode &= ~ReifiedWorkMode; + } + } } // Walk the parent path to the root and update the child expiration time. let node = fiber.return; @@ -470,17 +491,44 @@ function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { alternate = node.alternate; if (node.childExpirationTime < expirationTime) { node.childExpirationTime = expirationTime; + if (enableReifyNextWork) { + if (force === true) { + // console.log('marking path as reified'); + node.mode |= ReifiedWorkMode; + } else { + // console.log('marking path as NOT reified'); + node.mode &= ~ReifiedWorkMode; + } + } if ( alternate !== null && alternate.childExpirationTime < expirationTime ) { alternate.childExpirationTime = expirationTime; + if (enableReifyNextWork) { + if (force === true) { + // console.log('marking alternate path as reified'); + alternate.mode |= ReifiedWorkMode; + } else { + // console.log('marking alternate path as NOT reified'); + alternate.mode &= ~ReifiedWorkMode; + } + } } } else if ( alternate !== null && alternate.childExpirationTime < expirationTime ) { alternate.childExpirationTime = expirationTime; + if (enableReifyNextWork) { + if (force === true) { + // console.log('marking alternate path as reified'); + alternate.mode |= ReifiedWorkMode; + } else { + // console.log('marking alternate path as NOT reified'); + alternate.mode &= ~ReifiedWorkMode; + } + } } if (node.return === null && node.tag === HostRoot) { root = node.stateNode; diff --git a/packages/react-reconciler/src/ReactTypeOfMode.js b/packages/react-reconciler/src/ReactTypeOfMode.js index 4f053e5bba47..c253dea6d653 100644 --- a/packages/react-reconciler/src/ReactTypeOfMode.js +++ b/packages/react-reconciler/src/ReactTypeOfMode.js @@ -9,10 +9,11 @@ export type TypeOfMode = number; -export const NoMode = 0b0000; -export const StrictMode = 0b0001; +export const NoMode = 0b00000; +export const StrictMode = 0b00001; // TODO: Remove BlockingMode and ConcurrentMode by reading from the root // tag instead -export const BlockingMode = 0b0010; -export const ConcurrentMode = 0b0100; -export const ProfileMode = 0b1000; +export const BlockingMode = 0b00010; +export const ConcurrentMode = 0b00100; +export const ProfileMode = 0b01000; +export const ReifiedWorkMode = 0b10000; diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 2ebf8d16a85e..55583383b175 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -103,7 +103,8 @@ describe('ReactHooks', () => { // Update that bails out. act(() => setCounter1(1)); - expect(Scheduler).toHaveYielded(['Parent: 1, 1']); + // expect(Scheduler).toHaveYielded(['Parent: 1, 1']); + expect(Scheduler).toHaveYielded([]); // This time, one of the state updates but the other one doesn't. So we // can't bail out. @@ -130,7 +131,8 @@ describe('ReactHooks', () => { // Because the final values are the same as the current values, the // component bails out. - expect(Scheduler).toHaveYielded(['Parent: 1, 2']); + // expect(Scheduler).toHaveYielded(['Parent: 1, 2']); + expect(Scheduler).toHaveYielded([]); // prepare to check SameValue act(() => { @@ -152,7 +154,8 @@ describe('ReactHooks', () => { setCounter2(NaN); }); - expect(Scheduler).toHaveYielded(['Parent: 0, NaN']); + // expect(Scheduler).toHaveYielded(['Parent: 0, NaN']); + expect(Scheduler).toHaveYielded([]); // check if changing negative 0 to positive 0 does not bail out act(() => { diff --git a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js index 34d854f78cdd..669c2f816f22 100644 --- a/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js @@ -54,14 +54,18 @@ describe('ReactNewContext', () => { function Consumer(props) { const observedBits = props.unstable_observedBits; let contextValue; - expect(() => { + if (ReactFeatureFlags.enableContextSelectors) { contextValue = useContext(Context, observedBits); - }).toErrorDev( - observedBits !== undefined - ? 'useContext() second argument is reserved for future use in React. ' + - `Passing it is not supported. You passed: ${observedBits}.` - : [], - ); + } else { + expect(() => { + contextValue = useContext(Context, observedBits); + }).toErrorDev( + observedBits !== undefined + ? 'useContext() second argument is reserved for future use in React. ' + + `Passing it is not supported. You passed: ${observedBits}.` + : [], + ); + } const render = props.children; return render(contextValue); }, @@ -70,14 +74,18 @@ describe('ReactNewContext', () => { React.forwardRef(function Consumer(props, ref) { const observedBits = props.unstable_observedBits; let contextValue; - expect(() => { + if (ReactFeatureFlags.enableContextSelectors) { contextValue = useContext(Context, observedBits); - }).toErrorDev( - observedBits !== undefined - ? 'useContext() second argument is reserved for future use in React. ' + - `Passing it is not supported. You passed: ${observedBits}.` - : [], - ); + } else { + expect(() => { + contextValue = useContext(Context, observedBits); + }).toErrorDev( + observedBits !== undefined + ? 'useContext() second argument is reserved for future use in React. ' + + `Passing it is not supported. You passed: ${observedBits}.` + : [], + ); + } const render = props.children; return render(contextValue); }), @@ -86,14 +94,18 @@ describe('ReactNewContext', () => { React.memo(function Consumer(props) { const observedBits = props.unstable_observedBits; let contextValue; - expect(() => { + if (ReactFeatureFlags.enableContextSelectors) { contextValue = useContext(Context, observedBits); - }).toErrorDev( - observedBits !== undefined - ? 'useContext() second argument is reserved for future use in React. ' + - `Passing it is not supported. You passed: ${observedBits}.` - : [], - ); + } else { + expect(() => { + contextValue = useContext(Context, observedBits); + }).toErrorDev( + observedBits !== undefined + ? 'useContext() second argument is reserved for future use in React. ' + + `Passing it is not supported. You passed: ${observedBits}.` + : [], + ); + } const render = props.children; return render(contextValue); }), @@ -1326,6 +1338,7 @@ describe('ReactNewContext', () => { }); describe('readContext', () => { + // @TODO revisit this test with enableReifyNextWork and enableContextSelectors it('can read the same context multiple times in the same function', () => { const Context = React.createContext({foo: 0, bar: 0, baz: 0}, (a, b) => { let result = 0; diff --git a/packages/react-reconciler/src/__tests__/ReactSpeculativeWork-test.js b/packages/react-reconciler/src/__tests__/ReactSpeculativeWork-test.js new file mode 100644 index 000000000000..af34905f6deb --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactSpeculativeWork-test.js @@ -0,0 +1,466 @@ +let React; +let ReactFeatureFlags; +let ReactNoop; +let Scheduler; + +let levels = 5; +let expansion = 3; + +xdescribe('ReactSpeculativeWork', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + }); + + it('exercises bailoutReducer', () => { + let _dispatch; + + let App = () => { + return ; + }; + + let Parent = () => { + return ; + }; + + let Child = () => { + let [value, dispatch] = React.useReducer(function noZs(s, a) { + if (a === 'z') return s; + return s + a; + }, ''); + Scheduler.unstable_yieldValue(value); + _dispatch = dispatch; + return value; + }; + + console.log('------------------------------------ initial'); + const root = ReactNoop.createRoot(); + ReactNoop.act(() => root.render()); + expect(Scheduler).toHaveYielded(['']); + expect(root).toMatchRenderedOutput(''); + + console.log('------------------------------------ dispatch a'); + ReactNoop.act(() => _dispatch('a')); + expect(Scheduler).toHaveYielded(['a']); + expect(root).toMatchRenderedOutput('a'); + + console.log('------------------------------------ dispatch b'); + ReactNoop.act(() => _dispatch('b')); + expect(Scheduler).toHaveYielded(['ab']); + expect(root).toMatchRenderedOutput('ab'); + + console.log('------------------------------------ dispatch z'); + ReactNoop.act(() => _dispatch('z')); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('ab'); + + console.log('------------------------------------ dispatch c'); + ReactNoop.act(() => _dispatch('c')); + expect(Scheduler).toHaveYielded(['abc']); + expect(root).toMatchRenderedOutput('abc'); + + console.log('------------------------------------ dispatch zd'); + ReactNoop.act(() => { + _dispatch('z'); + _dispatch('d'); + }); + expect(Scheduler).toHaveYielded(['abcd']); + expect(root).toMatchRenderedOutput('abcd'); + + console.log('------------------------------------ dispatch ezzzzfzzz'); + ReactNoop.act(() => { + _dispatch('e'); + _dispatch('z'); + _dispatch('z'); + _dispatch('z'); + _dispatch('z'); + _dispatch('f'); + _dispatch('z'); + _dispatch('z'); + _dispatch('z'); + }); + expect(Scheduler).toHaveYielded(['abcdef']); + expect(root).toMatchRenderedOutput('abcdef'); + }); + + it('exercises reifyNextWork', () => { + let externalSetValue; + let externalSetMyContextValue; + + let App = () => { + let ctxVal = React.useContext(MyContext); + let [value, setMyContextValue] = React.useState(ctxVal); + externalSetMyContextValue = setMyContextValue; + + return ( + + + + + + + + + + + + + + + + + + + ); + }; + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + + render() { + return this.props.children; + } + } + + let Intermediate = React.memo(function Intermediate({children}) { + return children || null; + }); + let BeforeUpdatingLeafBranch = React.memo( + function BeforeUpdatingLeafBranch({children}) { + return children || null; + }, + ); + let AfterUpdatingLeafBranch = React.memo(function AfterUpdatingLeafBranch({ + children, + }) { + return children || null; + }); + let Leaf = React.memo(function Leaf() { + return null; + }); + + let MyContext = React.createContext(0); + + let UpdatingLeaf = React.memo( + function UpdatingLeaf() { + let [value, setValue] = React.useState('leaf'); + let isEven = React.useContext(MyContext, v => v % 2 === 0); + Scheduler.unstable_yieldValue(value); + externalSetValue = setValue; + return `${value}-${isEven ? 'even' : 'odd'}`; + }, + (prevProps, nextProps) => prevProps === nextProps, + ); + + let root = ReactNoop.createRoot(); + + ReactNoop.act(() => root.render()); + expect(Scheduler).toHaveYielded(['leaf']); + expect(root).toMatchRenderedOutput('leaf-even'); + + ReactNoop.act(() => externalSetValue('leaf')); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('leaf-even'); + + ReactNoop.act(() => externalSetMyContextValue(2)); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('leaf-even'); + + ReactNoop.act(() => { + externalSetValue('leaf'); + externalSetMyContextValue(4); + }); + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('leaf-even'); + + ReactNoop.act(() => externalSetMyContextValue(5)); + expect(Scheduler).toHaveYielded(['leaf']); + expect(root).toMatchRenderedOutput('leaf-odd'); + + ReactNoop.act(() => { + externalSetValue('bar'); + externalSetMyContextValue(4); + }); + expect(Scheduler).toHaveYielded(['bar']); + expect(root).toMatchRenderedOutput('bar-even'); + + ReactNoop.act(() => externalSetValue('baz')); + expect(Scheduler).toHaveYielded(['baz']); + expect(root).toMatchRenderedOutput('baz-even'); + }); + + it('enters advanced context tracking mode when you read from different contexts in different orders', () => { + const ContextProviderContext = React.createContext( + React.createContext('dummy'), + ); + + const NumberContext = React.createContext(0); + const StringContext = React.createContext('zero'); + + let Consumer = () => { + let ContextToUse = React.useContext(ContextProviderContext); + let value = React.useContext(ContextToUse); + return value; + }; + + class Indirection extends React.Component { + shouldComponentUpdate() { + return false; + } + + render() { + return this.props.children; + } + } + + let App = ({ContextToUse, numberValue, stringValue, keyValue}) => { + return ( + + + + + + + + + + ); + }; + + let root = ReactNoop.createRoot(); + + console.log('---------------------- initial render with NumberContext'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('1'); + + console.log('---------------------- remount render with NumberContext'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('1'); + + console.log('---------------------- change numberValue render'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('2'); + + console.log('---------------------- switch to StringContext render'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('two'); + + console.log('---------------------- remount on NumberContext render'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('3'); + + console.log('---------------------- switch to StringContext render'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('three'); + + console.log('---------------------- switch back to NumberContext render'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('3'); + + console.log('---------------------- switch back to StringContext render'); + ReactNoop.act(() => + root.render( + , + ), + ); + expect(root).toMatchRenderedOutput('three'); + }); + + let warmups = []; + let tests = []; + + function warmupAndRunTest(testFn, label) { + warmups.push(() => + it(`warmup(${label})`, () => testFn(`warmup(${label})`)), + ); + tests.push(() => it(label, () => testFn(label))); + } + + warmupAndRunTest(label => { + ReactFeatureFlags.enableContextReaderPropagation = false; + ReactFeatureFlags.enableReifyNextWork = false; + runTest(label); + }, 'regular(walk)'); + + warmupAndRunTest(label => { + ReactFeatureFlags.enableContextReaderPropagation = true; + ReactFeatureFlags.enableReifyNextWork = false; + runTest(label); + }, 'regular(reader)'); + + warmupAndRunTest(label => { + ReactFeatureFlags.enableContextReaderPropagation = false; + ReactFeatureFlags.enableReifyNextWork = true; + runTest(label); + }, 'speculative(walk)'); + + warmupAndRunTest(label => { + ReactFeatureFlags.enableContextReaderPropagation = true; + ReactFeatureFlags.enableReifyNextWork = true; + runTest(label); + }, 'speculative(reader)'); + + warmupAndRunTest(label => { + ReactFeatureFlags.enableContextReaderPropagation = false; + ReactFeatureFlags.enableReifyNextWork = true; + runTest(label, true); + }, 'speculativeSelector(walk)'); + + warmupAndRunTest(label => { + ReactFeatureFlags.enableContextReaderPropagation = true; + ReactFeatureFlags.enableReifyNextWork = true; + runTest(label, true); + }, 'speculativeSelector(reader)'); + + warmups.forEach(t => t()); + tests.forEach(t => t()); +}); + +function runTest(label, withSelector) { + let Context = React.createContext(0); + let renderCount = 0; + + let span = Consumer; + let selector = withSelector ? value => 1 : undefined; + let Consumer = React.forwardRef(() => { + let value = React.useContext(Context, selector); + let reduced = withSelector ? Math.floor(value / 3) : value; + // whenever this effect has a HasEffect tag we won't bail out of updates. currently 33% of the time + React.useEffect(() => {}, [reduced]); + renderCount++; + // with residue feature this static element will enable bailouts even if we do a render + return span; + }); + + let Expansion = React.memo(({level}) => { + if (level > 0) { + return ( + + {new Array(expansion).fill(0).map((_, i) => ( + + ))} + + ); + } else { + return ( + <> + + + + ); + } + }); + + let ExtraNodes = ({level}) => { + if (level > 0) { + return ( + + {new Array(expansion).fill(0).map((_, i) => ( + + ))} + + ); + } else { + return 'extra-leaf'; + } + }; + + let externalSetValue; + + let App = () => { + let [value, setValue] = React.useState(0); + externalSetValue = setValue; + let child = React.useMemo(() => , [levels]); + return {child}; + }; + + let root = ReactNoop.createRoot(); + + ReactNoop.act(() => root.render()); + + ReactNoop.act(() => { + externalSetValue(1); + }); + + for (let i = 2; i < 10; i++) { + ReactNoop.act(() => { + externalSetValue(i); + }); + } + + console.log(`${label}: renderCount`, renderCount); +} diff --git a/packages/react-refresh/src/ReactFreshRuntime.js b/packages/react-refresh/src/ReactFreshRuntime.js index 350363ce876a..66c2185de28d 100644 --- a/packages/react-refresh/src/ReactFreshRuntime.js +++ b/packages/react-refresh/src/ReactFreshRuntime.js @@ -149,11 +149,14 @@ function isReactClass(type) { function canPreserveStateBetween(prevType, nextType) { if (isReactClass(prevType) || isReactClass(nextType)) { + console.log('this update CANNOT preserve state'); return false; } if (haveEqualSignatures(prevType, nextType)) { + console.log('this update CAN preserve state'); return true; } + console.log('DEFAULT CASE this update CANNOT preserve state'); return false; } diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index c725b0e7c54e..f49cc72071f0 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -12,6 +12,7 @@ import type { ReactEventResponder, ReactEventResponderListener, } from 'shared/ReactTypes'; +import {enableContextSelectors} from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import {REACT_RESPONDER_TYPE} from 'shared/ReactSymbols'; @@ -40,7 +41,7 @@ export function useContext( ) { const dispatcher = resolveDispatcher(); if (__DEV__) { - if (unstable_observedBits !== undefined) { + if (!enableContextSelectors && unstable_observedBits !== undefined) { console.error( 'useContext() second argument is reserved for future ' + 'use in React. Passing it is not supported. ' + @@ -53,6 +54,21 @@ export function useContext( : '', ); } + if ( + enableContextSelectors && + typeof unstable_observedBits === 'number' && + Array.isArray(arguments[2]) + ) { + console.error( + 'useContext() second argument is reserved for future ' + + 'use in React. Passing it is not supported. ' + + 'You passed: %s.%s', + unstable_observedBits, + '\n\nDid you call array.map(useContext)? ' + + 'Calling Hooks inside a loop is not supported. ' + + 'Learn more at https://fb.me/rules-of-hooks', + ); + } // TODO: add a more generic warning for invalid values. if ((Context: any)._context !== undefined) { diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 0dd89f7674a5..d8a02a544c3a 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -130,3 +130,12 @@ export const warnUnstableRenderSubtreeIntoContainer = false; // Disables ReactDOM.unstable_createPortal export const disableUnstableCreatePortal = false; + +// Turns on speculative work mode features and context selector api support +export const enableContextSelectors = true; + +// Turns on context reader propagation +export const enableContextReaderPropagation = true; + +// Turns on a work reification implementation +export const enableReifyNextWork = true;